Skip to content

Umbrella: parse once into Elements, and make every component consume them (#211) - #229

Merged
milyin merged 56 commits into
mainfrom
language-integration
Aug 4, 2026
Merged

Umbrella: parse once into Elements, and make every component consume them (#211)#229
milyin merged 56 commits into
mainfrom
language-integration

Conversation

@milyin

@milyin milyin commented Jul 29, 2026

Copy link
Copy Markdown
Owner

This body carries the stage plan and its evidence, and is where stage state is edited. The repo's docs/language-integration.md is the design record — what an Element is, why the syntax rides along — and keeps no live counts.

Ledger: 202 seeded → 116 now. api/core 11, cbindgen 25, jnigen 80.

L0–L2 done. L4 in progress (#264 the dispatch entry point; #267 the classify.rs leak; #271 the emitters' representation assumptions; #272 the selector's; #274 nullability + the struct chain; #276 the trait itself; #278 core's plan leaves; #279 the adapter's reading sources; #285/#286/#288 the lookup surface; #293 what a spelling adds over its classification, which found one live miscompilation; #302/#303 the .jobject_input() decoders, which found a second; #304 the sum's match pattern, a third). L5 started early#276 took the item methods, because they were the last thing keeping the spelling accessors alive; #280 sealed TypeRef, so the model is now the only thing that can mint a reading, and #293 made what a spelling adds over its kind the model's answer rather than a peel each rebuilding emitter writes; #298#301 then made TypeKey blind, so a key is an identity and no longer a second door to syn::Type. L2 reopened and closed again by #283, which found a classification driver the ledger cannot see. L3 not started.

Read the count with its blind spot. It measures who matches syn variants, not who reasons from origin. #263 removed fourteen round trips and moved it by zero; #264 removed the adapter's most load-bearing one and moved it up by one. Each stage note says which claim its PRs are making.

Parse once, consume elements everywhere — integration map

Umbrella for making every component of prebindgen consume core::flat's
Elements instead of parsing captured Rust itself.

#211 remains the authority on
the invariants and the frontend/adapter boundary. This document does not restate
them; it records the design this program follows, what has landed, and the order.

This body is the one place stage state is edited — the first paragraph says so,
and this sentence used to say the opposite ("this file … change the doc, then
re-sync the umbrella"), left over from when the text lived in the doc. The doc is
the design record and keeps no live counts.

The design

Source(s) ──items──> Flat ──Elements──> Registry ──> adapters
  raw records          parse +           projects       classify off `kind`
  (syn::Item)          resolve           the model      spell off `origin`

An Element is two things at once, and the pairing is the whole point:

  • a closed classificationTypeKind, the field list, which of the two enum
    shapes an item is — that says what the source means, in terms every
    destination language shares;
  • one Origin, carrying the exact syntax the node was built from and the
    source it arrived in. Every node has one, at every level — item, parameter,
    field, alternative, type, array extent.
pub struct Origin<S>  { pub syntax: S, pub location: Rc<SourceLocation> }
pub struct TypeRef    { pub kind: TypeKind, pub origin: Origin<syn::Type> }
pub struct Param      { pub name: syn::Ident, pub ty: TypeRef, pub origin: Origin<syn::PatType> }

The two enum shapes are separate entities, because they are numbered differently
and consumed as different constructs:

pub struct Variant { pub name: syn::Ident, pub alternatives: Vec<Alternative>, .. }  // a sum
pub struct Enum    { pub name: syn::Ident, pub values: Vec<EnumValue>, .. }          // C-style

Why the syntax rides along

The predecessor design (#215)
built a syn-free semantic model and kept hitting one wall: the generated Rust
glue is itself a destination artifact, and it is the only consumer that needs
syntax fidelity. Each time it did, the answer was to model the syntax —
DiscriminantSource::Explicit(syn::Expr), syn::Member, syn::Lifetime,
to_syn(), and finally a VariantShape whose only job was to make generated
Rust spell E::B() instead of E::B. A model that carries no syntax has to
become lossless to serve that consumer, which is how a language-neutral IR
turns back into a second syn.

Carrying the original slice costs nothing and removes the pressure, so the
classification stays small and genuinely neutral:

Fact Where it lives Who reads it
B() vs B Alternative::origin.syntax (via spell::fields) generated Rust only
= 0x07 vs = 7 EnumValue::origin.syntax.discriminant a C mirror re-emits it
the number 7 EnumValue::discriminant Kotlin NAME(7), jint decode
which alternative of a sum Alternative::index a sum has no Rust number to borrow
Foo<'a, T> TypeRef::origin.syntax generated Rust only
"it is a Foo" TypeKind::Named every adapter
[u8; TAG_LEN] — spelling / number / const identity TypeRef::origin.syntax / ArrayExtent::value / ExtentSource::Const C header / Kotlin / both
the Box in Box<Option<T>>, and the Option<T> under it TypeRef::erased_wrappers() / stripped_syntax() — derived from the syntax, not stored an emitter that rebuilds or destructures a Rust value
where an item came from Origin::locationabsent for a synthesized one diagnostics

The rule

Classify off kind, spell off syntax.

Matching a syn::Type or syn::Expr variant outside core::flat is a
classifier, and #211 says classification lives there alone. Passing an
Origin's syntax into quote! is spelling, and spelling the source is exactly
what generated Rust must do.

This is mechanically measured, and needed no new mechanism:
core::flat::boundary (ported from
#224) counts variant mentions
of watched syn enums per file, so quote!(#slice) is invisible to it while
matches!(ty, syn::Type::Reference(_)) is counted. The committed ledger is the
scoreboard for this whole program.

Size of the problem

Seeded by L0 at 202 classification sites outside the frontend, split
api/core 71, cbindgen 25, jnigen 106. The second population it was seeded
alongside — 113 reads of the registry's syn-keyed item maps — is gone:
L1.5 deleted those maps, so every one of those reads now goes through the model.

Those are the numbers this document keeps, because a seed is a fixed fact. The
current count is in boundary.ledger
, which is generated, and its stage-by-stage
history is in #229. A table of
live counts copied into prose here would be wrong after the next merge, and was.

Two things the falling count has taught, which the count itself does not show:

A site leaving is not the same as a site migrating. The largest single drop was
#248 deleting a pattern engine
whose tables held one entry in the whole crate. Nothing was migrated to read an
element; the code holding the sites went away. Both are real progress, and a stage
that does not say which one it achieved is not reporting.

Not every site must go: some inspect types the adapter itself synthesized
wire types, converter signatures — which is legitimately the adapter's business.
Separating the two populations is not a document to write up front; it is each
entry's fate as it comes off the ledger, with a stated reason in the PR that
moves it.

Stages

Stage Owns State
L0 The parser, Element, and the ledger done#227
L0.5 Flat: the model, indexed and resolved done — this branch
L1 Registry consumes elements done#238
L1.5 The model is the only index done#239#246
L1.75 The registry becomes describable done#249#253, squashed into #248's commit
L2 api/core stops classifying source syntax done#248, #257, #258, #261, #263, #283
L3 Cbindgen consumes elements not started
L4 JniGen consumes elements (the long pole) in progress#264#305
L5 Close the seam: the public contract stops being syn started early#276, #280, #293, #298#301

L0 — the parser — done (#227)

Acceptance is preserved, not expanded. An item the language cannot express
becomes Element::Unsupported carrying its diagnosis, because the pipeline has
always scanned a signature only once an adapter declared it, and a source crate
may mark items no binding uses. Only a duplicate name — which no declaration can
disambiguate — fails the parse. Tuple-struct fields stay unmodelled for the same
reason.

L0.5 — Flat: the model, indexed and resolved — done

L0 produced a Vec<Element>, which nobody could ask anything. This stage makes it
a model, and takes two bullets off L1 in the process.

  • core::languagecore::flat, LanguageFlat: the thing being
    modelled is the flat API
  • Element = Function | Type | Constant | Unsupported, with Struct,
    Variant, Enum and Opaque under Type; the type reference becomes
    TypeRef
  • Opaque is an entity, declared by #[prebindgen] pub type X = .. — the way
    a foreign or crate-private handle gets a name in the flat API. This is
    the prerequisite for everything below it
  • FlatBuilder collects, Flat answers by name: function,
    declared_type, constant, element, the per-kind iterators, resolve
  • References resolve at parse time. An item naming an undeclared type is
    Element::Unsupported with ItemError::UnresolvedType — so a dangling name
    is reported here, by name, instead of surfacing downstream as an unresolved
    converter from whichever adapter looked first
  • &mut MaybeUninit<T> becomes RefMode::Out — an out-parameter is a
    property of the borrow, not a wrapper type, and it is a boundary concept
    every destination language has (C's T *out)
  • The example flat APIs are closed, and covertest-kotlin's build script
    asserts they stay closed across both its sources
  • Did not move: every generated artifact byte-identical

Cow<'_, T> needed neither an alias nor a grammar addition in the end: it is
transparent, exactly like Box<T>, so it lowers to whatever T is
(#236). Both adapters already
treated it as Vec<T>, which is what made the transparency the honest reading
rather than a convenience.

Still open: zenoh-flat and its two consumers are separate repos. Their
unmarked types — the 26 zenoh aliases, plus Duration, which is not in the
prelude and so needs a marked alias like any other foreign type — need the same
treatment before they parse.

L1 — Registry consumes elements — done

The seam that makes the direction real. Adapters were not touched.

  • Registry::from_flat(Flat); from_items is Flat::builder + from_flat,
    so both entry points share one parser
  • The registry holds the model (registry.flat()), which is how L2–L4
    reach it: an adapter already has the registry
  • The maps are a projection of the elements — plus synthesis, since resolve
    injects adapter-declared binding-local fns into functions
  • scan_fn_signature's receiver / parameter-pattern / impl Trait guards
    deleted with their ScanError variants, along with index_item,
    check_no_duplicate and first_seen_loc: Flat owns indexing and
    duplicate detection
  • ParseError::DuplicateName carries both crate names, so one authority
    produces the message
  • Did not move: every generated artifact byte-identical

Correctness is checked by default, superseding L0's "inert until declared":
ingestion fails on anything the language cannot express, listing every offender at
once so a source crate needing migration sees one list. An opt-out for
deliberately-unsupported elements is #237.

The cost landed in test fixtures: 167 of 524 tests held an item naming a type they
never declared. test_util::declare_referenced supplies a marked alias for those
where the handle is incidental; the rest were real corrections — a path-qualified
std::time::Duration that no declaration can name, and two array-length tests
asserting shapes the subgrammar dropped in #212.

Still open: zenoh-flat's 26 unmarked aliases. Until they are marked,
zenoh-flat-c and zenoh-flat-jni do not generate.

L1.5 — the model is the only index — done

L1 made the registry a projection of the model, but it still kept its own copies.
A projection that copies is two stores that can disagree, so this stage deleted the
copies. Not planned as a stage; it fell out of reviewing L1 and is recorded here
because the map should show where the program actually went.

  • The seven fields go (#243):
    functions, structs, enums, consts, guards, item_origins,
    source_modules. Flat grows struct_type / enum_item /
    source_modules, and the registry answers origin_module,
    default_module, named_item_idents off the model. The
    SourceLocation half of every deleted map entry was provably dead — all
    44 .get() sites bound it to _
  • Binding-local fns join the model, lowered through the same grammar
    (Flat::lower_signature) and admitted by add_local_function — otherwise
    "one index" would be a lie, since a sig!(..) never passed through the parser
  • The type table carries the reading
    (#239): a cell is
    TypeCell { subject, root, entry }, the subject being the frontend's
    TypeRef. required stopped being stored — it was one name over three
    storages — and is derived by resolve. The subject was originally a
    two-variant TypeSubject, the second variant meaning "a type only the
    binding authored, with no reading"
    ; L2 found that population empty and
    deleted it, so every cell now carries a reading
  • const _ is a Guard, not a Constant
    (#240): an anonymous const
    has no address, so it is not API. Four sentinel ident == "_" checks had
    already gone dead without anyone noticing — the failure mode a sentinel invites
  • A lookup takes the name the caller holds
    (#244): the sealed Name
    trait, because Ident hashes via to_string() and has no Borrow<str>
    the allocation can be moved, never removed
  • An alias is a declaration of its name
    (#245): the two type
    diagnostics had excluded Extern as an artefact of asking the old
    structs/enums maps, which had nowhere to put one
  • Flat owns the type index
    (#246): the last index
    living outside its owner. from_flat collapses to check expressibility,
    store the model
    . Canonicalization becomes one definition
    (canonical_type, moved into core::flat::spelling by L2) that both the
    index and TypeKey derive from
  • A reading and a reportable position are different facts: a synthesized
    signature has readings but no file, so SourceLocation::has_position gates
    what diagnostics print. Fixed a pre-existing :0:0: for hand-built streams
    as well

What is left in Registry is now genuinely its own: the two type tables
(adapter answers plus roots) and the five adapter-declared plan maps.

L1.75 — the registry becomes describable — done

Also not planned as a stage, and it moves no ledger sites — the count is 167
before it and 167 after. It is here for the same reason L1.5 is: once L1.5 made
the registry a projection with nothing of its own to hide, its API could be
closed, and closing it is what makes a generator for a fourth language writable
by someone who has not read resolve. Tracked by
#251.

  • The caller states its declarations; the registry stops asking
    (#249) — the five
    decomposition callbacks become one handed-over value
  • Say what the registry is for
    (#250): which type
    conversions a binding needs, and whether it has them all.
    Its module doc
    had been a list of fields, and a stale one since Flat is the only index; Registry stops keeping a second one #243 deleted them
  • State the shape, then build it
    (#252): RegistryBuilder
    and Registry are two types because being-described and finished are two
    states. 13 Prebindgen hooks called from 9 points inside resolve become
    describe, hand over the answers, read it. Nothing calls back into the
    generator
    — not by trait hook, and not by a next_request/supply pull
    loop, which is the same protocol with the arrow reversed
  • The generator owns the model and the registry
    (#253): a build script
    names one type. JniGen::builder().source(..).build() replaces the
    Flat::builder()Registry::builder()resolvewrite_* dance;
    Flat and Registry stop being names a build.rs has to know

All of it is on this branch, in one commit. The stack landed PR-into-PR onto
flat-drop-pattern-engine, and #248 squash-merged that branch afterwards, so
d845c8f — titled for the pattern engine — carries the registry and generator
redesign too. Do not read the commit log as the inventory: flat-drop-pattern-engine
still reports 28 commits ahead of language-integration because a squash records
no ancestry, while the trees differ by nothing. Diff the content, not the history.

L2 — api/core stops classifying source syntax — done

  • The pattern engine is deleted
    (#248): match_pattern,
    unify, immediate_pattern_children, substitute_wildcards, both rank
    tables. The general machinery composed converters for any parametrized type;
    its tables held one entry in the whole crate, Result<_, _>, which the
    model already names TypeKind::Fallible. 592 deletions against 124
    insertions, and the ConverterImpl tail extracted verbatim rather than
    rewritten. Ledger 202 → 167
  • The scan walks the model's edges
    (#257): registry/walk.rs
    is deleted and immediate_edges takes its children from TypeKind. Three of
    its arms were dead rather than migrated — the grammar refuses non-unit tuples
    and raw pointers, and Group/Paren are transparent. Ledger 167 → 158
  • A composed type is classified where it enters, and the answer is kept:
    expansion builds spellings the source never wrote, so ensure_entry asks the
    grammar once, when a cell is born, and stores the reading in that cell.
    Flat is consulted, never extended — its index means what the source
    wrote
    , and a wire-side intermediate is not that
  • Every cell carries a reading: with the above, the "no reading" half of
    TypeSubject had no members left (measured: zero refusals across every
    in-tree example and the whole suite), so the enum is gone. A spelling the
    grammar really does refuse is now a reported error naming it, rather than a
    cell that quietly means less than its neighbours
  • Spelling moves to its owner: canonical_type, normalize_type,
    type_from_ident and the rest become core::flat::spelling. They decide what
    spelling a type has before anything keys on it — the same authority that
    decides what it means. Ledger 158 → 154
  • One layer read (#261):
    the twenty sites in unfold and expand that peeled Option, then Vec,
    then & by taking a spelling apart now read the model's arity stack.
    Ledger 154 → 135
  • The reading is carried, not re-derived
    (#263): fourteen sites still
    reached into origin.syntax for a fact the element already held — a
    Function::ret that is a TypeRef, callback arguments that are TypeRefs.
    The helpers now take &TypeRef, so the round trip does not compile. The
    ledger did not move
    , which is the finding, not a footnote — see below

#248 is deletion, not migration, and the distinction is worth keeping visible:
35 sites left because their code left. The same caveat applies to the spelling
move, which is a move. Only the last item above is a migration in the full
sense, and it is the one that took the most arguing.

What L2 taught: a peel must match what the consumer can build

Three defects in the layer read, all one root — a peel that answered more than its
caller could represent — and none of them visible to the evidence this programme
usually relies on. The suite passed and regen stayed byte-identical through all
three, because no in-tree example exercises the shapes involved.

  • Vec<T> matched a T constructor. Expansion builds one value; its plan
    shape has no iterable arm. A peel that removed the Sequence anyway made a
    Vec<T> parameter match a T constructor, and the wrapper would have handed one
    reconstructed T to a parameter expecting the collection.
  • The stack recursed past its own contract. Vec<Option<T>> read as an
    optional inside a run, so a return matched a decomposition target T and
    installed a fold — for a type the explicit path next to it refuses outright. Two
    paths disagreeing about one return, the silent one winning.
  • Layers was a fourth copy of core::shape::Shape, whose own module doc says
    it replaced three. Encoded as flags, so a caller could only ignore a layer it
    could not build; the stack lets it decline by not matching.

What came out of it is the rule, and it outlives the stage: the peel is chosen by
the consumer's capability, not by the type's structure.
TypeRef therefore
offers both — layer_stack for a consumer that implements every layer, and
optional_inner / sequence_elem / borrow_target for one that composes exactly
what it can honour.

What L2 taught twice: the ledger measures the wrong thing for this

The fourth defect was the measurement itself, and it is the one worth carrying
furthest. L2 was first reported done on the strength of the count falling 154 → 135.
Then a review pointed at this, which had survived all of it:

let item_fn = flat.function(&f).map(|f| f.origin.syntax.clone())?;
let ret = fn_return(&item_fn);        // dig the return out of raw syntax
returns_type(registry, &ret, &key)    // -> classify() -> re-lower it

Function::ret is already a TypeRef with kind computed at parse time. The
model handed the answer over; the code reached into origin and derived it again —
in six places, with five more re-extracting callback arguments the model held as
TypeRefs, and three digging parameters out of a cloned ItemFn.

The ledger could not see any of it. It counts variant mentions of watched syn
enums per file, outside core::flat
, so moving a match into one shared classifier
drops the count without changing the data flow. Both facts are real, and they are
different facts:

The ledger measures who matches syn variants. It does not measure who
reasons from origin
. Those came apart the moment the matching moved into one
place, and only the second is what #211 asks for.

The fix is a signature rather than a checker — peel, peel_borrow and
returns_type take a &TypeRef, so a caller must already hold a reading and the
round trip does not compile. Flat::classify belongs to the registry, which is the
authority on what a type means because it is the thing that stores readings.
origin.syntax is read only where a value is stored for emission.

So a count is a proxy, and this one has a known blind spot. A stage that reports
only its delta is reporting the proxy. Where a rule can be made structural, it
should be — the deltas L3 and L4 report are worth exactly as much as the invariants
they can point at underneath them.

What L2 taught a third time: a driver the ledger cannot see at all

L2 was reported done twice — once on the count falling 154 → 135, then again after
the origin-reasoning correction above. #283
found a third driver that survived both, and the ledger moved by zero for
it, before and after:

FoldLeaf { ty: pty.optional() }                  // expand.rs — kind + spelling, paired
registry.require_output(leaf.out_ty.syntax());   // unfold.rs — only the SPELLING crosses
let subject = self.flat.classify(ty)?;           // scan.rs   — classified again, own twin stored

A composed reading was built, discarded at the registry door, and independently
re-derived — two classifications of one type, by two paths that never met. It was
latent, not active: nothing in the tree compared the two answers, so a
disagreement would have produced wrong output and no signal. The #266 shape again.

The fix is not where the issue first said. #281
proposed moving the composers behind a registry API; that would have closed
nothing, because the loss is at the door, and it happened again at every
recursion step — immediate_edges had each child as a &TypeRef and called
.syntax().clone() so the next level could re-classify it. The correction was
posted on the issue before implementing.

One rule: a type enters the registry as a reading; only a spelling nobody has
classified yet goes through classify.

ensure_entry, register_type_*, require_* and unrequire_* take a TypeRef;
immediate_edges returns one; intern / intern_recursive are the single
fallible door, pub(in crate::api::core) so an adapter still cannot mint a
reading by classifying tokens of its own. Flat::classify is down to ONE
production caller.

Two things fell out rather than being argued for. Infallibility: ensure_entry
was fallible for exactly one reason — classify refusing a spelling — and a
reading has already been through that, so #281's plan to assert that layering is
total and pin it with a test became unnecessary. And ten of the twelve
require_* sites already held a TypeRef
and called .syntax() at the door, so
carrying it was a deletion.

regen-check byte-identical is evidence here, not a regression check: the cell
used to hold classify(spelling) and now holds the caller's reading, so identical
output is the first confirmation that the two answers agree for every type the
examples exercise.

Where api/core ends, and why it is not zero

11 sites: types_util 9, registry/scan 2. unfold's last one — peel_ref — went with #278, and one types_util helper with #279.

What L4 could delete that L2 could not: SumSpec
(#305, −243 lines).
types_util::{SumSpec, SumVariant, SumField} described a data-carrying enum
as a tag plus one leaf group per variant — which is flat::Variant /
Alternative / Field, down to SumField::ty: syn::Type where the element
carries a classified TypeRef. A second model of a sum, living inside
api/core, invisible to the ledger because it names no syn::Type variant.

Its stated reason for existing did not survive measurement. Two adapter
comments said it "owns the leaf-NAMING convention", so retiring it needed that
rehomed first — nothing read SumField::name; jnigen names slots with its
own sum_field_prop_name / sum_slot_fragment. SumField::ty,
SumSpec::key and SumSpec::source had no readers either: four of nine
fields. And its doc justified it as shared — "both adapters read one
definition instead of growing a private one each"
— where cbindgen never
used it once
. The #[allow(dead_code)] on all three structs was the tell.

Three of its four last call sites were zipping it back against the
sum.alternatives they already held
: derived from the item, then re-paired
with the element it was derived from. Same shape #303 removed from
flat_input.rs. This is what "only L3 and L4 can free them" looks like when
the thing to be freed is a whole parallel model rather than a helper.

Every classifying helper still in types_util is called overwhelmingly from the
adapters — option_inner_type 40 times, bare_path_ident 22, is_unit 18 — and
none takes the model as an argument, so it cannot consult it from the inside. L2
stopped api/core from calling them; only L3 and L4 can free them to be deleted.

The two in registry/scan are different and stay for good: they inspect a key a
build-script author wrote, to diagnose that spelling — no source type is being
classified, so there is no element to read instead. They are the first entries to
land in the "legitimately the adapter's business" category this document predicts.

L3 — Cbindgen consumes elements

  • builder (8), trait_impl (6), emit (5), mod (5), convert (1)
  • Variant patterns and constructors come from Variant::spell, not from
    re-deriving delimiters
  • A discriminant is re-emitted from Variant::syntax, and the number comes
    from Variant::discriminant
  • Generated C artifacts byte-identical

L4 — JniGen consumes elements — in progress

  • The lookup surface takes the reading
    (#284
    #285,
    #286,
    #288): The registration path carries readings instead of re-deriving them #283 made
    registration reading-based; this did the same for lookup.
    reading(&TypeKey) is the one keyed door, conversion / input_entry /
    output_entry take a &TypeRef, and reading_of(&syn::Type) is the
    visible "I only had tokens" step. So an entry lookup cannot be called
    about a type the registry does not know
    Seal TypeRef: only the model may mint one #280 sealed minting, so a
    TypeRef comes only from the model or from those doors

    **The find that mattered was not in the trait, and the build did not
    report it.** `Registry` carried *inherent* `input_entry`/`output_entry`
    taking a `&syn::Type` (`scan.rs:579/585`), and an inherent method wins
    over a trait method on a concrete receiver — so every caller holding a
    `Registry` used the spelling door. Changing the trait alone left **104
    sites** compiling against the old path; closing the inherent pair is what
    surfaced them. The same "second door inside the room" that hid `classify`
    behind `Registry::reading` until #267
    
    **On the premise.** #284 was prompted by making `TypeKey` private.
    Measurement said that would move *nothing* — access was by **spelling**,
    not by key, so privatizing removes zero of the 103 lookups. `TypeKey`
    stays public as adapter-facing identity (43 map/set uses; no build script
    names it), and `TypeRef` deliberately did **not** gain `Hash`/`Eq`:
    #283's acceptance test discriminates on `location()`, and an `Eq`
    comparing only the key would re-hide that defect
    
    `to_type()` 45 → 37, none feeding a lookup. **The ledger did not move
    through any of the three**, at 127, and each PR reported the absence
    rather than dropping the prediction its plan had made — it counts syn
    *variant mentions*, and a signature change names none. The remaining 37
    are #291's, below
    
  • A sum's match pattern keeps its own delimiters
    (#304): encode_sum_group
    built each arm by branching on variant.fields.first(), so an alternative
    with no fields took the None arm and was spelled bare — myflat::E::B
    for enum E { B() }, which is E0533: a zero-field tuple or struct
    variant still needs its delimiters in pattern position

    **The third instance of one defect class, and the count is the point.**
    #302 got it wrong in a constructor (`Unit {}`, caught in review); #303 got
    it right by using `Alternative::spell`; this had it wrong in a *pattern*
    the whole time and nothing had found it. Two shapes of the same mistake:
    replacing a `syn::Fields::Named` guard with a per-field check, and
    branching on `fields.first()` — both lose the empty case, because an empty
    thing has no field to inspect. `Alternative::spell` is the model's answer
    and its doc names both halves: *"the one place those delimiters are chosen
    — for match patterns and constructors alike, in either direction"*
    
    Ledger unchanged; the fixture that would show it does not exist in-tree,
    which is exactly why the shape had no signal. Found while scoping #305
    
  • The .jobject_input() decoders read the element
    (#289
    #302,
    #303; the flatten path
    went first in #294):
    struct_input_body and sum_input_body walked syn::Fields while their
    callers already held the flat::Struct / were one declared_type away
    from the Type::Variant. flat_input.rs is now off the spelling
    census
    — 18 → 0 — with no option_inner_type, reading_of,
    bare_path_ident, pat_match_top, SumSpec or syn::Fields left

    **It found a live descriptor bug, which is the point.** A
    `Box<Option<i64>>` field got JVM descriptor `Ljava/lang/Object;` while its
    twin `Option<i64>` got `Ljava/lang/Long;` — Kotlin declares both `Long?`,
    and `GetFieldID` requires the exact declared descriptor, so that lookup
    throws `NoSuchFieldError`. `option_inner_type` reads the last path
    segment, so the `Box` answered "not optional" and the chain fell to its
    `Object` fallback. Unreached only because that converter is emitted and
    never called for the fixture; live for any `.jobject_input()` data class
    with a wrapped optional field. The same last-segment test also sat at
    **render** time choosing a `Box::from_raw` target, now structural
    (`direct_handle: Option<Box<syn::Type>>`)
    
    **And it re-taught the delimiter rule, in review.** Replacing the
    `syn::Fields::Named` guard with a per-field `name` check loses the empty
    case — an empty struct has no field to refuse — so a hard-coded braced
    initializer emitted `myflat::Unit {}` for `pub struct Unit;`. `Struct::spell`
    is the dual of the `Alternative::spell` #303 used for `E::B()`: the element
    does not record delimiters because they are *spelling*, and the model owns
    the one place they are chosen. Ledger 117 → 116; the four entries left in
    that file classify `entry.destination`, a wire the adapter produced itself
    

The long pole, and the counted 97 is the smaller half of it. Measured before
starting: 34 origin.syntax reads and ~118 calls to types_util's
classifying helpers, neither visible to the ledger. option_inner_type alone is
called 40 times from jnigen, bare_path_ident 22, is_unit 14. Those are the ten
helpers L2 could not delete, and L4 is what frees them.

  • The crossing hands over the reading
    (#264): convert_crossing
    rebuilt a spelling with key.to_type() and re-classified it, while the
    registry held the reading in the cell — jnigen's currency was TypeKey, so
    the whole dispatch ran on spellings. Conversions gains reading, which
    both the partial and total views answer from the same cell, and the selector
    takes a &TypeRef. Fixing the door is what stops a file-by-file migration
    from producing new instances underneath itself

  • The layer questions, selector half
    (#272): converter
    selection decided what a type was by rendering a wildcard pattern from
    its spelling and comparing the string (pat_match(pat, "Option < _ >")
    ×10), then rebuilt the type it generates by name
    (parse_quote!(Option<#t1>) ×7). Box<Option<T>> reconstructed as
    Box<_>, matched nothing, and got no converter at all — while
    select_output_type was handed only a spelling, because convert_crossing
    fetched the reading and discarded it. Dispatch is TypeKind now and the
    signature is origin.syntax; pat_match, with_first_arg and
    ref_wildcard are deleted, and selector.rs went 8 → 3. Transparent
    wrappers became one tablecore::flat owns the set it erases,
    the adapter owns what Rust can do with each, and a test fails if they
    disagree — so adding Rc is one entry and one row rather than a hunt

  • The layer questions, Kotlin surface + the struct chain
    (#274, Kotlin nullability is decided by is_option_type, a by-name check #273):
    is_option_type decided nullability by name, so a wrapped Option
    parameter rendered non-null while the identical-meaning plain one
    rendered String? — a wrong contract, since Kotlin then rejects null at
    the call site. Conversions gained the model accessors, and the
    struct/data-class chain went further and carries the element:
    classify_field takes &flat::TypeRef, build_struct_plan takes
    &flat::Struct, the sum walk zips flat::Variant::alternatives. Asking
    about an unregistered type is a compile error there now, and two runtime
    guards became unrepresentable. is_option_ref deleted

  • The layer questions, remainder: emit/flat_input (20),
    emit/wrapper, emit/delivery, fold, trait_impl — the counts are
    committed per file in jnigen's spelling_census, which walks tokens and
    resolves use … as … (the sibling adapter aliases these helpers today).
    This is what retires option_inner_type and peel_ref_option_vec

  • Core's plan leaves carry the reading
    (#278):
    UnfoldLeaf::out_ty and FoldLeaf::ty were syn::Type, and the reading
    was discarded at constructionflatten peels TypeRefs and stored
    origin.syntax. Most producers simply stop discarding; five genuinely
    compose a type no source wrote (a borrow, two Option layers, a
    presence flag, a selector), and Flat::classify is rightly unavailable to
    them, so flat gained TypeRef::{borrowed, optional, scalar, named}
    each pairing kind with its own spelling. Composed-from-nothing is
    placeless, the call classify already makes.
    a_composed_type_keys_as_its_spelling pins the thing that would otherwise
    break silently: a composed &T keying differently would register a
    different cell

  • Adapter-side reading sources, and the last accessor goes
    (#279, closing
    #275): the remaining 3
    callers were both places the adapter obtains a type rather than consumes
    one — callback args reconstructed from TypeKeys instead of read off
    TypeKind::Callback { args: Vec<TypeRef> }, and a sum payload taken from a
    &syn::Field. trait_impl stopped .map(|a| a.origin.syntax.clone())-ing
    away args it already held; write_sealed_classes zips
    flat::Variant::alternatives. Conversions::is_optional is deleted
    grep -rn "is_optional" prebindgen/src returns only PathStep::is_optional,
    which was this issue's acceptance test. Ledger 129 → 127

    Two things it changed beyond the count. `derive_iface_spec` resolves keys
    back to readings and **defers** on a miss (`?`) — where `is_optional`
    answered `false`, i.e. rendered a non-null Kotlin param, the #273 shape.
    And `write_sealed_classes` now asserts on the model's `Type::Variant`
    distinction: the `else { continue }` it replaced had turned a
    `sealed_class!` misdeclaration from a diagnosis into a silent skip
    
  • Names and identity: emit/names (17), render (8), overloads. TypeId
    is the name; bare_path_ident takes a path apart to re-derive it

  • The enum shape: emit/convert (4), plus enum_shape,
    enum_discriminant_values, first_payload_variant — L2c, which turned out
    to be entirely L4's

  • classify.rsmostly legitimate, and the ledger cannot see it at all.
    It answers "how is this type declared to me" (Handle / Enum / Sum
    from DeclaredKind), which is the adapter's own business. Its one leak was
    DataStruct { st: &syn::ItemStruct }, where flat::Struct exists —
    closed by #267, which
    needed it: a field record could not carry its own reading while the walk
    was handed a syn::ItemStruct. walk_value_form now iterates
    flat::Struct.fields, and unfold.rs left the ledger entirely (136 → 135)

  • reject_unsupported_array_length re-checks a grammar flat::array_len owns
    Array-length qualification needs one walk, not a validator and a rewriter that disagree #210's drift one layer down. Delete it, with the subset check as evidence

  • The reading comes from the declaration
    (#267): Registry::reading
    fell back to Flat::classify on a miss, and the fallback fired on
    scalarsi64 ×48, String ×25 — because unfold's value-form walk
    asked about the leaves its caller registers one loop later. classify
    answered correctly, so an ordering bug produced correct output and no
    signal. FieldRecord::ty is now a TypeRef, reading is a pure lookup,
    and L4b-1/L4b-2 are unblocked

  • The emitters stop assuming a Rust representation
    (#271, Value-form field: an Option behind a transparent wrapper emits an undereferenced access #268): the dual of
    L4a: the crossing hands over the reading #264's defect, and the direction nothing was watching. L4a: the crossing hands over the reading #264 fixed consumers
    re-classifying from origin.syntax; this fixed an emitter classifying off
    kind correctly and then spelling off kind too — match place { Some(..) } is E0308 the moment the source writes Box<Option<T>>, which
    the model erases by design. Three destructures coerced; a token-walking
    census over the whole emit/ directory keeps a fourth from appearing
    silently

  • Generated Rust and Kotlin byte-identical

What stays, and it finally has members. QualifyEmittedTypes walks generated
items to qualify paths, and the wire-shape matching in prim / prim_array /
wire_access inspects JNI types the adapter synthesized. Both are the exemption
this document predicted, and each PR that touches them records it rather than
leaving it implicit.

#264 is also the clearest case yet that the count is the wrong axis. It removed
the re-parse at the dispatch door — the single most load-bearing round trip in the
adapter — and the ledger went up, 135 → 136, because the spelling guard it
added is two honest syn matches. A stage that reported only its delta would have
scored that as a regression.

What L4 taught: an erasure sits outside the layer it wraps

The model erases Box and Cow, and that erasure is right — Box<Option<T>>
is one optional to every destination. But conversion follows the syntax, and
the two facts a rebuild needs were not on the model.
#293 added them as derived
readings, TypeRef::erased_wrappers() and stripped_syntax() — derived because
a stored spelling is a second thing that can disagree with syntax, and #290's
erased_wrapper() had already established the shape. Defined by an invariant
rather than by a loop: the stripped spelling is the one whose own lowering
yields exactly this kind
, so the peel runs to a fixed point (Box<Box<T>>
classifies as T, and one strip leaves a Box<T> that does not match). The test
asserts that property and was seen to fail against a one-layer implementation.

The rule, which outlives the stage:

kind is precisely the thing the wrapper is missing from, so interpreting
kind before checking for a wrapper always discards one.

Box<&Vec<T>> classifies as Ref; peel that first and the wrapper is gone from
everywhere a consumer will look. &Box<Vec<T>> hides it on the referent, where a
question asked of the outer syn::Type::Reference cannot see it. Neither check
subsumes the other and the two classify identically, so a walk must ask at
every layer on the way down — which is also why the wrapper is a list,
gathered as the walk descends rather than read once at the top.

The audit found the population is two, and only one has to ask. A site that
classifies — which Kotlin type, which JNI wire, which C spelling — must never
consult the wrapper; that is the erasure working, and every cbindgen site and all
but one jnigen site are of that kind. A site that binds a source value and
destructures or rebuilds it
must. There was exactly one unguarded instance and
it was a live miscompilation: builder delivery bound the returned value and
matched it against Option's patterns, which match ergonomics does not see
through a Box. Fixed at the single point the value enters the delivery, not at
each of the four matches downstream. #290's own comment had flagged the output
side and cbindgen as unchecked; this is that check.

Two findings about evidence, which is what this stage keeps being about.

  • jnigen: the enum probe peels off the model, then syn::Type parameter cleanup #290's guards were a hand-maintained set of peel sites and were wrong twice in
    one PR. "Are all the peel sites guarded?" is answered by inspection until the
    model carries the facts and one shared helper consumes them — which is the
    argument for doing the model half first, against the issue's own "not urgent".
  • The suite could not see the defect at all. 737 contains(..) assertions
    pass on Rust that does not compile
    (#269). The fixture that
    proves this one is in perftest-flat, whose generated binding covertest
    include!s and builds — the only place in the tree where an E0308 is a
    test failure. Verified by disabling the fix and watching the build break, then
    round-tripped on the JVM. The ledger, again, did not move: no watched syn
    variant site is added or removed.

L5 — close the seam

The public contract stops being syn, which is what stops the population from
growing back.

  • Registry's public item maps stop being the adapter-facing contract —
    done early by L1.5, which deleted them outright; relates to
    #92

  • What a spelling adds over its classification is the model's answer
    (#293):
    erased_wrappers() / stripped_syntax(). Belongs to this stage because
    the alternative was every rebuilding emitter taking a syn::Type apart for
    itself — the population growing back through a door the ledger does not
    watch. The completion criterion below already forbids reconstructing a
    spelling from a classification; this is the fact that makes obeying it
    possible, and it is deliberately derived so the model gains no
    representation state to keep in sync

  • The item methods take elements
    (#276):
    on_function/on_struct/on_enum/on_const were the widest part of the
    public syn surface and the one that decided what adapters could know
    an adapter handed a flat::Function cannot ask what a parameter means and
    be told "no reading". on_enum split into on_variant + on_enum along
    the model's own distinction; they still sort together, only dispatch
    differs. Nine sites stopped .map(|f| &f.origin.syntax)-ing away an
    element they had just fetched. Zero external cost: all six implementors
    are in-crate. Landed out of stage order because it was blocking L4's
    accessor deletion, not because L5 started

  • A key is an identity, not a second door to syn::Type
    (#291
    #298,
    #299,
    #300,
    #301): Seal TypeRef: only the model may mint one #280 sealed
    minting, and TypeKey::to_type() walked straight around the seal — it
    kept the parsed form beside the canonical string and handed it to anyone
    holding a key, so a caller could spell a type the model never classified.
    TypeKey is now the Rc<str> alone. 44 call sites → 0, and the
    generated output was byte-identical at every one of the four steps

    The four are worth reading as one argument. **A** took the key
    `immediate_edges` was already re-deriving — the commonest use of
    `to_type()` was undoing itself. **B** is the design point: a declaration
    that wrote `ptr_class!(Foo)` should keep that `Foo`, so decls carry
    `Origin<syn::Type>` and `export_type` takes the type like its sibling
    `cross` always did. That is also what unblocked the two core sites a key
    genuinely could not serve — the qualified-path diagnostic needs multi-
    segment *structure*, and `intern` needs real tokens for a type in no table
    yet. **C1** gave the key the questions it can answer about itself
    (`ident()` / `short_name()`, read off the canonical string, never a
    reparse). **D** removed the field
    
    **The issue's predicted cost did not materialise.** It expected ~5 sites
    turning infallible into `Option`; none did. Every declare-phase consumer
    reads its own declaration — `reading()` there would legitimately answer
    `None` and silently change behaviour — and the two sites that did move to
    `reading()` each sit beside a sibling that already did. The one place the
    key was *silently* doing work is recorded in the code: it canonicalized,
    so every site that switched to declaration tokens calls `canonical_type`
    explicitly or `crate::Foo` starts reading as a qualified path
    
    C1 is also the clearest ledger movement in the programme — 120 → 117, with
    `kotlin_emit.rs` leaving entirely, because it *retired classifiers* rather
    than re-typing signatures. `types_util` still did not move, and #302 said
    so again: its helpers keep callers elsewhere
    
  • Only the model may mint a reading
    (#280): TypeRef was
    pub struct { pub kind, pub origin } with four public composers, so any
    consumer could assemble one and nothing checked that kind agreed with
    origin.syntax. Holding one proved nothing. Fields are pub(super),
    composers and Flat::classify are pub(in crate::api::core), and reads go
    through kind() / syntax() / location()a public field IS a
    constructor
    , so restricting only the composers would have blocked
    nothing.

    The invariant is deliberately **unconditional** — no phase, no lifetime, no
    direction — because it has to hold for a *stored* value: a `TypeRef` lives
    in `UnfoldLeaf::out_ty` and `FoldLeaf::ty`, inside plans the registry
    itself stores, so any borrow-carrying token would make the registry
    self-referential. It does **not** claim the converters exist, which is
    false by design for stored readings (`unrequire_output` leaves a cell whose
    converter genuinely cannot resolve; a `SumTag` leaf never has one), so
    `TypeRef → TypeEntry` stays **0..2** and converter existence stays a lookup
    
    **Two review rounds, both hitting the same seam one level out.** The first:
    the doc claimed "nothing above the model can mint one" while everything was
    `pub(crate)`, and `jnigen/emit/sum_out.rs` already minted — false at the
    commit that claimed it. The second: routing that site through
    `Variant::type_ref()` composed from `self.name`, and `Variant` was itself
    assemblable, so an out-of-crate consumer got `Named` over the spelling
    `String` (which the model reads as `Str`) — the very disagreement being
    sealed. It terminates because the reading is now **data the parser
    produced**, stored on `Variant`, rather than a function of caller-supplied
    fields
    
    **The ledger did not move** (127), and cannot: it counts syn *variant
    mentions*, and a field access names none. That blind spot is what the new
    census in the follow-ups below has to close
    
  • Prebindgen::post_process_item(&mut syn::Item) — the hook that let
    qualification live in an adapter in the first place

  • ConverterImpl::function / TypeEntry::function as syn::ItemFn;
    prerequisites / local_functions returning raw items

  • Niches { value: syn::Expr, matches: syn::Expr } — a semantic fact carried
    as raw expression syntax

  • Extend the ledger's WATCHED beyond Type / ExprItem, Fields,
    FnArg, ReturnType, GenericArgument, Pat — one enum at a time, each
    addition a regenerated ledger whose diff is the decision

  • Close or accept the blind spots the ledger header lists (token-string
    classification, ident-name classification, helper delegation)

#280's follow-ups. #281 is
closed by #283 — see "a driver
the ledger cannot see at all"
under L2. Two remain:

  • The source-declared population. scan_fn / scan_struct / scan_enum still
    take syn items and walk field.ty / pt.ty / sig.output as raw syntax, while
    flat::Field::ty, Param::ty and Function::ret are already TypeRefs — the
    same discard The registration path carries readings instead of re-deriving them #283 removed, for source types rather than composed ones. Same rule,
    second population, separately reviewable
  • #289the emission
    population, same defect.
    emit/flat_input.rs walks syn::Fields::Named while
    flat::Struct::fields already carries a TypeRef per field, which is why it holds
    20 of the 34 option_inner_type callers. Not a helper swap: the peels are wrong
    (option_inner_type reads the last path segment, so Box<Option<T>> answers "not
    optional"), and the file is the reason they persist. The first change in this
    sequence that should actually move the ledger
    types_util is 9 of the 11
    remaining api/core entries, stuck since L2 precisely because its callers had no
    model to consult
  • The syn::Type census — storing or composing syn::Type above the model.
    Must come after the two above, which define its population, and it needs a
    new counter:
    boundary.ledger sees neither a syn::Type-typed field nor
    parse_quote!(Option<#t1>), since neither names a variant. spelling_census
    is the precedent to extend

Completion criteria

#211's, restated for this design:

  • One documented entry point from captured records to elements —
    Flat::builder().items(..).build(), which Registry::from_items also routes
    through.
  • Both Cbindgen and JniGen take every source fact from an element.
  • No component re-derives a source fact by matching captured syntax; the ledger
    has reached the irreducible set, and every remaining entry is documented as
    inspecting adapter-synthesized types.
  • The accepted Rust subset is covered by the acceptance matrix with precise
    diagnostics naming item and component.
  • Spelling generated Rust is done by re-emitting a syntax slice, never by
    reconstructing one from a classification.

Relationship to #215

#215 is superseded. Its four merged PRs are not lost: L0 ports the
array-length subgrammar (#212), the type grammar and its acceptance tests, the
enum tag/discriminant numbering (#226) and the boundary ledger (#224). What is
dropped is the syn-free model itself — SourceType::to_syn,
DiscriminantSource, VariantShape, NamedArg::Lifetime — because carrying the
source's own slice does that job without a modelling cost.

The source-frontend branch stays in place as the reference. Nothing depends on
it, and it is not a base for anything here: every stage of this program lands on
language-integration, which merges to main when the program does.

Review protocol

Each stage PR states its own exit:

  • Reported — what examples/regen-check.sh did, always. The check is
    instrumentation, not a constraint: it says what moved, not whether the
    change was allowed. Byte-identical is the strongest evidence a refactor did
    nothing unintended and is worth claiming when it holds — but generated output
    that moves without changing semantics or performance is fully acceptable, and
    no architecture decision may be reshaped to keep bytes matching.
  • Explained — if output moved, why the change is semantically and
    performance neutral. A movement outside that explanation is a bug.
  • Asserted — the invariant the stage adds, and the ledger delta it claims.

Run the check the way that makes it mean something: git clean -fd examples/
first (an earlier --all-features run leaves artifacts the check reads as drift),
then cargo clean -p example-cbindgen -p example-flat (it only regenerates what
cargo decides to rebuild, so a cached run passes without checking anything).

Umbrella document for making every prebindgen component consume `Element`s
instead of parsing captured Rust itself: the design and the rule it turns on,
the measured size of the problem (202 classification sites, 113 registry map
reads), the stage order L0–L5, and the completion criteria restated from #211.

This file is the authority on stage state; the umbrella PR body mirrors it.

Refs #211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@milyin

milyin commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Stage L0 is up as #227Language + Element + the ported ledger, seeded at 202 sites. Green on all four checks (build stable, build 1.85.0, covertest, smoke-asan); nothing consumes elements yet, so no artifact can move.

…227)

* jnigen: derive a return expansion from a value form (#213) (#221)

* jnigen: derive a return expansion from a value form (#213 Gap A)

`expand_return!(T).fields(fields!(t_to_struct))` takes T's output fields
from its value form — the struct gathering its own accessors — instead of
restating them. Two of zenoh-flat-jni's five hand-written lists had already
drifted from the struct they mirror; a derived list cannot.

`.fields()` is `.field()` applied to each struct field, so it keeps the
same rule: a field crosses by ITS OWN type's default output boundary. A
field type with an `expand_return!` splices it (a KeyExpr field still
crosses as its string, not as a handle), a declared data class inlines, a
field behind Option/Vec stays one leaf. Adopting it therefore preserves the
boundary shape a hand-written list already had.

Per-field adjustments live on the `FieldsDecl`, keyed on the Rust field
ident like `FunctionDecl::expand_param`: `.field(name, expand_return!(..))`
replaces one field's decomposition, `.name(name, "kt")` renames its leaf.
Naming a field the struct lacks is a hard error — that is the drift this
declarator exists to catch.

Core changes:
- `UnfoldLeaf.path` becomes `Vec<PathStep>` (`Call` / `Field`, each
  carrying its own optionality) so one path can mix accessor calls and
  field reads. Behaviour-preserving for every existing producer.
- `DeconRecord::Fields` + `FieldRecord`; the adapter walks the struct (it
  knows which are declared classes), core decides per field whether to
  splice, and rides the existing visited/Cycle guard.
- `UnfoldPlan.root_call` hoists the value-form call to one local, so the
  struct is built once per delivery rather than once per field.
- `Prebindgen::deconstructors` now takes `&Registry`, matching
  `value_struct_decons` — a value form's fields come off the indexed struct.

Sum-typed fields (ReplyStruct.result) are not covered yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* jnigen: a sum-typed field of a value form (#213 Gap A, sums)

`ReplyStruct { result: ReplyResult, .. }` — a `sealed_class!` field of a
value form now decomposes in place into its selector and one leaf group per
alternative. A sum has no whole-value converter by construction, so this is
the only shape in which it can cross at all.

The user-facing callback still receives ONE typed `ZOutcome`: the tag and
group slots collapse into a single parameter rebuilt by an inlined `when`,
reusing the `GroupDesc` collapsing that a fixed-builder arg already uses.
Handing the raw slots over would have defeated the `sealed_class!`.

Generalizations, both behaviour-preserving for a sum in the whole-return
position (its 20 existing tests are unchanged):
- the selector leaf carries the sum's own type as its `out_ty`, so the
  emitter finds the enum to match on from the leaf rather than from
  `plan.source` — which names the CONTAINING value once a sum is a field;
- `encode_sum_leaves` becomes `encode_sum_group`, taking one sum's leaf
  segment plus the expression to match on. `encode_plan_leaves` segments the
  leaf list and emits one match per sum instead of the whole plan being
  handed to the sum emitter; a whole-return sum is the degenerate case of
  one segment covering everything.

`Vec<sum>` and `Option<sum>` fields are refused by name: the first has
variable arity, the second would need a present flag beside its tag that an
output leaf list cannot carry (the `fromParts` bridge's `PlanFieldKind::Sum`
can, which is why a data-class field may be `Option<sum>`).

Also restores examples/example-cbindgen goldens, which the previous commit
picked up from an --all-features regeneration. The generator output is
unchanged; only the committed artifact was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* examples: restore example-cbindgen goldens to the plain-build variant

An earlier `git add -A` in this branch swept in an --all-features
regeneration, whose FEATURES guard reads
"example-flat/internal example-flat/unstable" instead of "".

`examples/regen-check.sh` builds with default features, so the committed
artifact has to be the default-feature one — this is what CI checks. The
generator output is unchanged either way; only the committed file was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* covertest: exercise the derived value-form boundary on the JVM (#213)

Library tests alone do not count as coverage in this repo, so `.fields()`
gets a real round trip: `perftest_flat::ext::Report` is a handle whose
output boundary is declared from its value form, with each field landing on
a different rule of the expansion —

  summary  a type with its own expand_return! ⇒ spliced into (count, total),
           NOT handed over as a handle
  taken    Option<data class> ⇒ one leaf
  origin   a non-optional data class ⇒ inlined into its fields
  outcome  a sealed_class! ⇒ selector + one group per alternative, carrying
           a handle
  label    a plain leaf

`Test.kt`'s new section is itself the assertion: the callback signature
would not compile if any field had been derived wrongly. It also pins the
ownership contract for a handle reached through a value form and a sum
group — live inside the callback, still live after, the receiver's to close.
47 sections pass on a real JVM.

Adds the Gap B unit test the issue asked for: a handle-payload sum in
DATA-CLASS FIELD position, the one position return/callback coverage did
not reach. It works — and the test pins two consequences that were
previously unstated: the container is NOT AutoCloseable (a sum payload is
the receiver's to close, unlike a plain handle field, which cascades), and
a sum field pushes its parent onto the whole-value fromParts bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* jnigen: address review on #221 — three value-form defects

P1 — a single-leaf value form passed a borrow to an owned converter.
One leaf makes core pick `Delivery::Return`, whose reach is composed
separately in `emit/wrapper.rs`'s `is_convert` path. That path rendered a
`Field` step as `&(expr).field` and returned it, so a plain field leaf —
whose `out_ty` is the field type as written — got `&F` where its converter
takes `F`, and a non-`Copy` field additionally borrowed out of the temporary
the value-form call returned. It now clones the reached place, the same
treatment `encode_plan_leaves` gives a `LeafSource::Field` leaf; an identity
leaf stays borrowed, since its converter IS the borrowed-opaque clone.

P2 — a per-field override did not validate its declared type.
`.field("key_expr", expand_return!(ZBytes)...)` was accepted for a `ZKeyExpr`
field whenever both were declared handles, and an override silently outlived
an upstream field-type change — the exact drift `.fields()` exists to catch.
The declared key is now compared against the peeled field type and names
both, matching the target checks on the per-function expansion APIs.

P2 — nested value forms were not hoisted.
`root_call` only searched the declaration's top-level records, so a field
splicing a child whose own boundary is also derived rebuilt that child once
per child leaf, breaking the stated "called once per delivery" contract.
Replaced by `UnfoldPlan.hoists: Vec<Vec<PathStep>>` — the path prefixes to
bind once, recorded where `flatten` descends and therefore outermost-first.
Each is composed from the longest already-bound prefix of itself, and each
leaf reaches off the innermost hoist it sits under:

    let __vf0 = z_outer_to_struct(&arg);
    let __vf1 = z_inner_to_struct(&(&__vf0).inner);

This also removes the single-value-form special case rather than adding a
second one beside it.

Three regression tests, one per finding. The only generated-output change is
the `__vf` -> `__vf0` rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* jnigen: validate nested value-form field shapes

* jnigen: consuming value forms — move the fields instead of cloning them

`.fields(fields!(f))` now accepts a value form that takes its receiver BY
VALUE. Such a form destroys the object into its parts, so the generated code
moves the value in and moves each field OUT into its leaf — the clones the
borrowing form pays disappear entirely.

This is what the hot receive path wants and what zenoh itself recommends:
`From<Sample> for SampleFields` exists, in zenoh's words, because it "allows
deconstructing a sample to fields without cloning, which is more efficient
than using getter methods". Every callback hands its value over owned
(`impl Fn(Sample)`), so there is nothing to preserve — the borrowing form
clones fields out of a value it is about to drop.

Measured on covertest's `Report`: six clones removed from the callback body,
`report_into_struct(__cb_arg0)` moved in, every field moved out.

Consuming-ness is INFERRED from the accessor's signature, so it cannot drift
from it, and both forms stay usable side by side.

Because a consuming form moves the value, two shapes are refused at
declaration time rather than emitted as Rust that cannot compile downstream:
a sibling record (`.field_self()` or another `.field()` would read a moved
value), and a form reached through another value form (it would move a field
out from under the parent's other leaves). A `&T`-returning function clones
once up front and consumes the clone, so one declaration still serves owned
and borrowed returns alike.

Two supporting changes:

- The reach derivation is now SHARED (`reach_leaf_flat`) between the
  multi-leaf encoder and the single-leaf `Delivery::Return` shortcut in
  emit/wrapper.rs. Deriving it twice is what let them drift into the P1
  defect; the shortcut also now refuses an optional intermediate step
  explicitly instead of composing code that cannot type-check.
- Reaches project the leading run of plain field steps DIRECTLY (`&v.a.b`)
  instead of through a borrow of the base (`&(&v).a.b`). The two name the
  same value, but the second borrows the base as a whole, which the borrow
  checker rejects once a sibling leaf has moved another field out — so
  without this, field moves compiled only while the borrowing leaves happened
  to be declared first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* jnigen: `.fields_into()` — declare the consuming value form, and let it nest

`6133f91` taught `.fields(fields!(f))` to accept a by-value accessor and
INFERRED consuming-ness from its signature. That reads the decision off the
wrong thing. Giving the value away is a boundary decision — the same one
`.field_self()` makes, which is exactly why the two cannot coexist — not a
property of which function happened to be named. So the collision surfaced as
a resolve-time error phrased as a restriction on `.fields()`, when it is really
two declarators to pick between.

Now the decl says which it wants:

    .field_self()                   the value itself, whole
    .fields(fields!(to_struct))     a copy of its parts
    .fields_into(fields!(into_))    the value itself, as its parts

`.fields_into(..)` must be the decl's only record — a `.field_self()` or a
sibling `.field(..)` would read a value that is gone — and that is now a panic
in the declarator, in BOTH orders, rather than an `UnfoldError` found a
resolve later. The declared flag and the accessor's receiver are cross-checked
when the records are flattened, so intent still cannot drift from the
signature; naming the wrong one of a `to_struct`/`into_struct` pair is an error
that says which declarator the accessor belongs to.

The nesting refusal is GONE. Its stated reason — "it would move a field out
from under the parent's other leaves" — does not hold: a hoisted value form is
an owned struct, its fields are disjoint, and `project_leading_fields` (same
commit) already stopped leaves from borrowing the base as a whole. So a nested
consuming form is handed the parent's field BY MOVE:

    let __vf0 = z_outer_to_struct(&__cb_arg0);
    let __vf1 = z_inner_into_struct(__vf0.inner);   // moved, not cloned
    …                                __vf0.tag …    // sibling leaf, still fine

`compose_step` borrows (`&(e).f`), so the field run to that field is projected
in the hoist loop instead of going through it. A nested form reached through an
accessor CALL holds a borrow with nothing to give up, so it clones once and
consumes the clone — the same fallback a borrowed root already takes. That was
the one place an available `_into_struct` went unused for no reason.

Verified: 438 lib tests (three retargeted, five new — both collision orders,
both signature-mismatch directions, and the nested move under a borrowing AND a
consuming parent), covertest-kotlin's 47 JVM sections, regen-check byte-clean.
The generated output for covertest is unchanged — same accessor, same moves;
only the declaration that names it moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* jnigen: address review on #221 — consuming ownership in two more places

Two review findings, both cases where `.fields_into(..)` promised a move and
the emitter did not deliver one.

[P1] The single-leaf `Delivery::Return` shortcut never consulted the plan's
hoists. It composed its reach straight off the raw value, so a one-field value
form declared with `.fields_into(..)` emitted

    (&(myflat::z_one_into_struct(&__cvsrc)).label).clone()

— `&ZOne` handed to a by-value receiver, ill-typed in the consumer's crate
before you even reach the pointless clone. Every consuming test so far produced
a MULTI-leaf callback plan and went through `encode_plan_leaves`, so nothing
covered it.

The hoist loop is now `bind_hoists`, shared by both paths, and `reach_leaf_flat`
takes the rebased path plus its hoist's `consuming` flag. The shortcut binds the
same `__vfN` locals as the multi-leaf encoder and reaches the leaf off the
innermost one. That is the same fix that was applied to the reach itself in
`6133f91` and for the same reason: two derivations of one question drift.

[P2] The identity branch computed `consuming` and then returned before using
it. Only a handle at the owned ROOT (empty path) moved; a handle FIELD always
took the clone-via-converter arm:

    ZChild_to_jlong_...(&mut env, &__vf0.child)

despite the parent form having given its value away — a preserved clone, and a
`Clone` bound the handle type need not have. The branch now computes the owned
PLACE (the root, or a plain-field run under a consuming hoist) and boxes it,
`Box::into_raw(Box::new(__vf0.child))`.

Both regressions reproduce the reviewer's exact shapes and both fail without
the corresponding fix (verified by stashing each).

Sum payloads, which the P2 comment also flagged, are NOT fixed here: filed as
#228. `encode_sum_group` matches by reference and clones every payload kind
through one chain, so moving means reworking that emitter's ownership model —
the selector reads the same matched value, and an owned handle payload wants
the identity branch's box rather than the borrowed-opaque converter. Not an
addendum to this PR.

Verified: 440 lib tests, covertest-kotlin's 47 JVM sections, regen-check
byte-clean (neither shape occurs in covertest, which is why its goldens do not
move — the unit regressions are what pin them).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* jnigen: decide leaf ownership in the plan, not in each emitter

Two more review findings on #221, both the same defect wearing a different
hat: an identity (handle) leaf under a consuming value form was still reached
as a borrow, so the borrowed-opaque converter cloned it — and demanded a
`Clone` the handle type need not have.

  * A value form whose SOLE field is a handle takes the single-leaf
    `Delivery::Return` shortcut. `bind_hoists` called the by-value accessor
    correctly, then the shortcut returned `&__vf0.child`, because its consuming
    case only covered `LeafSource::Field`.
  * An `Option<Handle>` field was excluded by the previous fix's plain-field
    test, leaving `match &(&__vf0).child { Some(__n0) => …clone… }` — an
    ordinary optional handle field, not the sum limitation of #228, and the
    commonest shape there is (`SampleStruct.attachment`).

Patching each emitter would have been a third special case for one question.
The question belongs to the PLAN: `place_is_owned` now decides, where an
identity leaf's `out_ty` is chosen, whether the value at that path is the
plan's to give away — the root of an owned plan, or a field of a form that
CONSUMED its value, reached by a movable run of steps. An owned `out_ty` IS
that statement, and it already selects the owning converter, so every emitter
follows one decision instead of re-deriving it.

`steps_are_movable` (plan.rs) is that run: field reads only, with an `Option`
allowed on the LAST one — a `None` arm still hands the whole `Option` over by
value, while an `Option` in the middle must be unwrapped and so can only be
borrowed through. The resolver and both emitters read the same predicate; two
readings would drift, and the disagreement is a borrow handed to an owning
converter.

Emitters then just project the place:

  * `reach_leaf_flat` moves whenever the leaf owns its `out_ty` — field and
    identity leaves alike. It keeps requiring a plain-field run, since return
    delivery has no `None` arm for a trailing `Option`.
  * The nullable identity branch matches the `Option` BY VALUE and boxes the
    `Some` payload, instead of matching a borrow of it.

Both regressions reproduce the reviewer's shapes and fail without the fix
(verified by stashing it). 442 lib tests, covertest's 47 JVM sections,
regen-check byte-clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* jnigen: a nullable sole leaf is a callback delivery, not a return

`single_return` chose `Delivery::Return` on leaf COUNT alone. A value form
whose only field is an `Option<Handle>` therefore landed on the flat return
path, which has no `None` arm and whose `convert_out_ty` names the leaf's own
type rather than an optional of it — so it composed

    &(&__vf0).child

into `ZChild_to_jlong(.., __out)`, typed for `ZChild`. The downstream crate
does not compile. Making `out_ty` owned in 421531e addressed move-vs-clone; it
says who frees the handle, not whether there is one.

Absence is a DELIVERY question. Callback delivery already has the arm — the
leaf crosses as a boxed `Long` or JVM null — so a nullable leaf goes there,
which is one condition on `single_return` rather than teaching the shortcut to
match and map a trailing option it has no way to represent in its return type.

Nullability here only ever comes from an `Option` with something DECOMPOSED
below it (a `.field_self()` handle, a nested value form); a plain leaf's own
`Option` rides its converter and leaves the leaf non-nullable. So no shape that
returns today stops returning — regen-check is byte-identical and covertest's
47 sections are unchanged.

Regression reproduces the reviewer's shape and fails without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* jnigen: an owned root identity moves on the flat return path too

The flat return path asked the wrong question. It tied the move to the rebased
hoist's `consuming` flag, but "a consuming form gave it to me" is only ONE of
the two ways a leaf owns what it reaches. A plain `-> ZChild` return under the
type-level `expand_return!(ZChild).field_self()` — the declaration that exists
so the same boundary can be spliced as a value-form field — has no hoist at
all, so `consuming` was false and the path emitted

    let __cvsrc = myflat::z_root_child_make();
    { &__cvsrc }

into the OWNING `ZChild_to_jlong`, whose argument is `ZChild`. Same mismatch
inside the `map` closure of an `Option<ZChild>` return.

For an identity leaf the plan already states ownership — that is what
`place_is_owned` decides and what selected the owning converter — so the
emitter reads it off `out_ty` instead of re-deriving it. A field leaf keeps
asking the enclosing form, since its `out_ty` is the field type as written and
owned either way.

That predates this PR: the previous shape of this path composed `&base` for an
empty path regardless. The callback emitter has always treated the owned root
as an owned place; now both do.

Regression covers the plain and the `Option` return and fails without the fix.
444 lib tests, covertest's 47 JVM sections, regen-check byte-clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* jnigen: rename `.fields_into()` to `.fields_self_into()`

Puts the declarator squarely in the `field_self` family it belongs to, which
is the whole point of it being its own declarator: `.field_self()` hands the
value over whole, `.fields_self_into(..)` hands *the value itself* over as its
parts, and `.fields(..)` hands over a copy of its parts. `self` is what the
first two share and what makes them mutually exclusive.

Mechanical: the method, the two panic messages, the doc links, the covertest
declaration and its coverage-table row. Generated output is unchanged —
regen-check byte-clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Parse the record stream into elements that keep their syntax

`Language` turns a captured `(syn::Item, SourceLocation)` stream into
`Element`s: a closed, destination-neutral classification paired, at every level,
with the exact syntax it was built from — the item, each parameter, field,
variant and type.

The pairing is the point. Issue #211 asks that adapters stop re-reading captured
Rust, and the natural reading of that — a syn-free semantic model — makes the
model responsible for reconstructing Rust too, because the generated glue is
itself a destination artifact. That pressure is what turns a language-neutral IR
back into a second `syn`: a delimiter, a lifetime and a literal's base all have
to be modelled so they can be re-emitted. Keeping the original slice costs
nothing and removes the pressure, so the classification stays small:

    Element::Enum → Variant { tag, discriminant: Option<i64>, fields, syntax }

`B()` is a unit *group* and still spells `E::B()`, because `Variant::spell`
reads the delimiters off `syntax`. `= 0x07` reaches a C header as `0x07` while
Kotlin gets the number 7. Neither is a modelled fact.

The rule for consumers is therefore: **classify off `kind`, spell off `syntax`.**
#224's boundary ledger measures exactly that without adaptation — it counts
variant mentions of `syn::Type` / `syn::Expr`, so `quote!(#slice)` is invisible
to it and `matches!(ty, syn::Type::Reference(_))` is not. It is ported here and
seeded at 202 sites, the population the adapter migrations pay down.

Acceptance is preserved, not expanded. An item the language cannot express
becomes `Element::Unsupported`, carrying its diagnosis: the pipeline has always
scanned a signature only once an adapter declares it, and a source crate may
mark items no binding uses. Only a duplicate name — which no declaration can
disambiguate — fails the parse.

Nothing consumes elements yet; `Registry::from_elements` is the next step. Ported
from the #215 branch: the array-length subgrammar (#212), the type grammar and
its acceptance tests, enum tag/discriminant numbering (#226), the ledger (#224).

Refs #211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Make the element model logical, not Rust-shaped

`TypeKind` still named Rust type constructors where it should have named
concepts, and the identity of a nominal type was a `syn::Path` sitting inside
the classification — a position the boundary ledger cannot see. The test a
variant has to pass is whether a *destination* language would act on the
distinction; if only Rust can tell, it is spelling, and the syntax slice already
carries it.

Twelve variants become ten:

* `Slice` folds into `Sequence`. `Vec<T>` and `[T]` are one concept — a run of
  `T` — and ownership is already the `Ref` layer's fact, so a second variant
  encoded it twice. This is what the pipeline does anyway: one `Shape::Iterable`
  covers both, and jnigen rewrites a `&[T]` input into the `Vec<_>` pattern.
* `Boxed` goes. `Box<T>` **is** `T`: owned either way, and nothing outside Rust
  can tell. It classifies as what it wraps, and the `Box` survives where it
  matters — in the syntax generated Rust spells.
* `Ptr` goes. No source crate writes a raw pointer, neither adapter has a
  selection arm for one, and accepting it *widened* acceptance, which this stage
  was not supposed to do.
* `Str` covers `str`, so `&str` is a borrowed string rather than a reference to
  a nominal type nothing can resolve. It is the most common non-scalar parameter
  in the whole ecosystem, and both adapters already special-case it by name.
* `Named` carries a `TypeId` — a name — instead of a `syn::Path`.

The same test applied to the elements: a function's return is a `Type`, unit
when elided, because no consumer distinguishes that from `-> ()` (eight of them
normalize one to the other on the spot). A struct's fields are
`Option<Vec<Field>>` — a product, or opaque — because named/unnamed/unit were
three Rust shapes where `Variant` already modelled the same idea as a field list
plus delimiters read off the syntax.

`spell.rs` now holds everything that turns an element back into Rust tokens, so
`element.rs` describes structure alone, and `Struct::spell` joins
`Variant::spell` as the dual that makes the shapes unnecessary.

Two things move to where they belong: `Language::parse` normalizes before
lowering (`ty.rs` already assumed it had), and the callback grammar
`extract_fn_trait_args` lives in the language rather than the registry — one
ledger site paid down, 202 to 201.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Delete the passthrough element

A `#[prebindgen]` crate marks the items that cross the boundary; the supporting
code around them belongs to the consumer. The proc-macro already enforces
that — marking a `use`, `mod`, `impl` or `macro_rules!` is a compile error at
the mark site — so the variant's own doc listed items that could never reach it.

What actually reached it was one thing: the `const _` feature guard, which is
not a source item at all. `CfgFilter` synthesizes it and prepends it to the
stream, so `Passthrough` existed to carry an item prebindgen itself wrote. It is
a const, so it is modelled as one, and `Element::name` returns `None` for `_` —
which is the real fact, and the one that lets several sources' guards coexist in
the flat namespace. `write.rs` already had that rule for consts (`*ident == "_"`
bypasses the declaration gate), dead until now because `const _` never reached
the consts map.

That leaves `union` and a type alias, the two kinds the macro accepts and the
frontend does not model. Neither is written by any source crate in the
ecosystem. They become `Unsupported` with a diagnosis naming the kind, rather
than being copied verbatim into generated code that would reference source types
by bare name — so the mark site and the frontend now disagree about exactly two
kinds, and disagree loudly instead of silently.

`Unsupported::name` becomes optional, since an item kind may have no identifier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Give every node one Origin: its syntax, and where that syntax came from

The classification is now logical, but its other half was still ad-hoc. `syntax`
sat on nine node types as nine separate fields; `location` sat on the five item
types only, because a captured record is per-item and a component has none of
its own.

That asymmetry had a cost. The one semantically load-bearing part of a location —
the crate name — was reachable at item level only, so it got copied downward by
hand, under a third field name, with drifting meaning: `ConstId.origin` is the
crate a const was *declared* in, while `TypeId.origin` was the crate of the item
*using* the type. The latter was also part of `TypeId`'s derived `Eq`, so
`Sample` referenced from two source crates compared unequal — one type with two
identities, three lines under a doc calling the name "the whole address".

The two facts are orthogonal and neither derives from the other. `syn` tokens
normally carry spans, but the proc-macro serializes each item as a string into
JSONL and `build.rs` re-parses it, so every span in a slice points into an
anonymous buffer; `SourceLocation::from_span` captures file/line/column while
real rustc spans still exist, precisely because they cannot survive the trip.

So every node now carries `Origin<S> { syntax: S, location: Rc<SourceLocation> }`
— item, parameter, field, variant, type, and the array extent, which had no
syntax at all and now spells its own length. Generic, so the typed slices
survive; `Rc` because the model holds `syn` and is `!Send` regardless, the call
`TypeKey` already made. One captured record is one item, so an item and every
node lowered out of it share one allocation, which is both the honest answer to
"where is this field" and the cheap one.

With provenance arriving on its own, `item_crate: Option<&str>` stops being
threaded through six lowering functions, `TypeId` is a name alone, and
`ConstId.origin` becomes `ConstId.crate_name` — a crate that belongs to a
*different* item, not this node's provenance.

The rule, now stated where it can be read: a reference carries a name, the
declaration carries the origin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* A variant's position is an index, not a tag

`Variant.tag: i32` and `Field.index: usize` were one fact under two names: the
ordinal of a child within its parent's ordered list. Sum versus product is
already carried by *which* list it is — `Enum::variants` or `Struct::fields` —
not by the number.

The defence for keeping them apart was that a tag is transmitted while an index
is only used to address a field. That defence was made of adapter behaviour:
`i32` because cbindgen writes `c_int` and jnigen writes `jint`. Deciding a
frontend field's shape from two generators' wire types is exactly the coupling
this module exists to prevent, and it is the same test that stripped `Boxed` and
`Slice` — a fact earns its shape from what the source means, not from what one
adapter does with it. Transmitting the position to say which alternative is live
is one destination's choice; another may send a name.

The signedness had no defence at all: a declaration-order position is `0..N-1`.

So `Variant.index: usize`, matching `Field.index`, and both documented as the
same fact for the same reason — a node handed out on its own still knows where
it sits. What remains genuinely distinct is `Variant::discriminant`: a position
is where the source *put* a variant, a discriminant is the value Rust *assigns*
it, and the two are independent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address review: extent identity, callback returns, i64::MIN

Three correctness fixes before this becomes the model later stages consume.

**`ArrayExtent` had an equality that was neither identity it could have been.**
It compared `value` and `source`, so `[u8; A]` differed from `[u8; 4]` when
`A == 4` — one Rust type reported as two — while `[u8; 4]` equalled `[u8; 0x04]`,
whose retained syntax differs. So it was not type identity and not spelling
identity, and its own doc claimed the first while the code did neither. There is
no single equality that could be right, because the extent answers three
different questions, so it now provides none and each consumer projects what it
needs: `value` for type and converter identity, `origin.syntax` for a C
declaration's spelling at that occurrence, `const_id()` for which consts must
reach the header. A regression pins all three apart — same value with different
const dependency, same value with different spelling, same value with different
const. The doc also records what a converter table will need: `value` being the
identity means occurrences share one converter with differing spellings, so a
canonical spelling must be chosen deliberately rather than inherited from
whichever occurrence populated the entry.

**The callback grammar silently dropped a return type.**
`extract_fn_trait_args` read `ParenthesizedGenericArguments::inputs` and never
`output`, so `impl Fn() -> u8 + Send + Sync + 'static` was accepted as
`Callback { args: [] }`. `TypeKind::Callback` has no slot for a return and the
grammar's own error text says a callback returns `()`, so the fact was lost —
silently, which is worse than refusing. A non-unit return is now refused, a
written `-> ()` still accepted, both with tests. No source crate in the ecosystem
writes a returning callback, so nothing real narrows. The helper predates this
PR, but making it the authoritative frontend classifier is what would have made
the loss irreversible for every later consumer.

**`i64::MIN` was not a discriminant.**
`int_literal` parsed the magnitude as `i64` before applying the sign, so
`-9223372036854775808` — valid Rust — failed at the digits. The magnitude is now
parsed as `i128` and range-checked after negation, with a regression at the
bottom of the range and one step past it.

Along the way, `is_unit_type` becomes the language's one answer to "is this
`()`", used by both the type lowering and the callback check. `types_util::is_unit`
could not serve: it is gated behind `unstable-cbindgen`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address review: async, variadic, generic binders, and a ledger hole

Three more shapes the frontend accepted but could not represent, and one hole in
the check that is supposed to catch exactly this class of thing.

**`async fn` was the dangerous one.** `Function` has a direct return, so
`pub async fn ping() {}` lowered as a function returning `()` — a generated
wrapper would call it, drop the future, and export a function whose body never
runs. A **C-variadic** tail was dropped from the signature just as quietly. Both
are now `ItemError`s.

**A type or const generic parameter is refused.** The elements have no generic
binder, so a `T` in a field or parameter lowered as `TypeKind::Named` — an
ordinary reference into the flat namespace, indistinguishable from a real item
called `T`, which loses the scoping every downstream resolver needs. Modelling
binders and substitution is the other option; refusing is the right one, because
no destination language can express an uninstantiated parameter, and the source
crates already write concrete types per instantiation. The diagnosis says so.

Two things are deliberately *not* generic binders, both tested. A lifetime
parameter: lifetimes are spelling and the spelling already travels, the same call
`lower_type` makes for a lifetime argument. And `impl Trait` in argument
position — Rust calls it an anonymous type parameter, but `syn` does not desugar
it into the binder list, so the callback form every callback-taking source
function uses is untouched.

**The boundary ledger could be evaded.** `is_cfg_test` treated any predicate
containing the ident `test` as test-only, so a classifier under `#[cfg(not(test))]`
or `#[cfg(any(test, feature = "x"))]` was skipped — in a production build. It now
matches the exact predicate `cfg(test)` and counts everything it cannot prove
test-only, which is the safe direction for a check whose job is to stop a
classifier hiding. `cfg(all(test, ..))` is genuinely test-only and is counted
anyway; nothing in the tree writes one, and widening it later should be a
deliberate edit with a ledger diff attached. The count does not move: every
`cfg` on an item in the tree is either exactly `cfg(test)` or mentions no `test`
at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Let Language read a source directory, not just a stream

A build script's whole prebindgen preamble was two steps and a binding it did not
otherwise want:

    let source = prebindgen::Source::new(zenoh_flat::PREBINDGEN_OUT_DIR);
    let registry = Registry::from_items(source.items_all())?;

`Language` now folds the first step in, so naming the directory is enough:

    let elements = Language::new()
        .source(zenoh_flat::PREBINDGEN_OUT_DIR)
        .parse()?;

That is five of the six consumer build scripts — zenoh-flat-jni, zenoh-flat-c,
perftest-c, perftest-kotlin, example-cbindgen — which use nothing of `Source` but
`new` and `items_all`.

Reading a stream is kept, as the general case rather than the only one:
`items()` takes any `(syn::Item, SourceLocation)` iterator, so everything a
`Source` can express still composes — a group selection, a renamed dependency
(covertest-kotlin's `crate_name` override, the sixth build script), several
sources at once. `source()` is sugar over it. The other four knobs on `Source`'s
builder — group selection and feature/target filtering — are reachable this way
and were not mirrored, because no build script in the workspace calls them.

The feeders accumulate and `parse` consumes, rather than each input being parsed
as it arrives. That is forced, not stylistic: the rules that make a parse fail are
whole-stream — one flat namespace, one const index an array length may reach
into, one set of source modules to normalize against — so every input must be in
hand before any of it is classified. A test now pins both directions of that: a
length in one feeder resolving a const from another, and a duplicate name across
feeders still failing.

`Language` and `Element` join `Registry` in the `core` facade, since they are what
a build script names; the rest of the element model stays in `core::language`,
where an adapter reaches for it.

The four doc examples on `Language` are now real doctests rather than `ignore`
blocks — `Source::init_doctest_simulate` was already there to make that possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Split the two enum shapes: a Variant is not an Enum

`Element::Enum` covered both a payload-carrying enum and a fieldless one, on the
theory that the second is the degenerate first. They are two entities, and the
evidence is in how they are numbered.

A sum's alternatives are identified by **position**: cbindgen states it outright —
"the mirror carries no explicit discriminants, so its tags are declaration order
`0..N`" — and jnigen's sum emission mentions `discriminant` exactly zero times
against eleven uses of the position. A fieldless enum's members are identified by
the **value Rust assigns**: a C header re-states each `= expr`, and a Kotlin
`enum class` entry is `NAME(7)`, with position only a fallback when the
discriminant is not a literal.

So one model covering both carried a field dead in each direction — and worse
than dead on the sum side, because Rust *does* assign a discriminant to a payload
alternative and using it would be wrong. The unified model invited exactly that
mistake.

    Element::Variant(Variant { alternatives: Vec<Alternative> })   // a sum
    Element::Enum(Enum { values: Vec<EnumValue> })                 // C-style

`Alternative` carries `index` and `fields` and no discriminant; `EnumValue`
carries `index` and `discriminant` and no fields. `discriminant_values` belongs to
`Enum` alone now. `is_unit` and `first_payload_variant` are gone: the first was
the classification, which `lower_enum` now makes once, and the second existed to
name an offender to an adapter that only accepts fieldless enums — such an adapter
matches `Element::Enum` and never sees the other shape.

Both shapes still spell delimiters off their own syntax, because `A`, `B()` and
`C {}` are fieldless alike and Rust demands the delimiters wherever the last two
are named — so `spell` is on `Alternative` and `EnumValue`, over the one
`spell::fields`. `enum E {}` and an all-empty-group enum are `Enum`; one field
anywhere makes the item a `Variant`, and a sum may still mix empty and
payload-carrying alternatives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
milyin and others added 3 commits July 30, 2026 09:36
…232)

* Rename the module to flat: these are the flat API's elements

`core::language` modelled one thing and was named for another. What it parses is
the **flat API** — the single flat namespace a `#[prebindgen]` crate exports — so
`Language` becomes `Flat` and `api/core/language/` becomes `api/core/flat/`.

Mechanical, and separated from the model changes that follow so those arrive as a
readable diff. The boundary ledger's skipped-path constant and header move with
the directory; the count does not change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Element is a function, a type, or a constant

`Element` mixed two levels: `Function | Struct | Variant | Enum | Const` set type
declarations beside functions and constants, when the kinds a binding
distinguishes are a function, a type, and a constant. Types now group under
`Element::Type`, and the type *reference* — which held the name `Type` — becomes
`TypeRef`, so a declaration and a use site stop sharing a word.

`Opaque` becomes the entity for a type whose contents do not cross, and it
arrives two ways:

* `#[prebindgen] pub type X = path;` — this **reverses** #227, where a marked
  alias was `Unsupported`. It is now how a handle enters the flat API
  deliberately: a foreign or crate-private type gets a name here without any
  claim about its contents. That is what makes the API closable, and it is the
  prerequisite for requiring references to resolve.
* a marked tuple struct, whose fields no adapter has ever crossed — unchanged
  acceptance, now named for what it always meant.

So `Struct::fields` drops its `Option`. `None` was the opaque case; an empty list
now means the source wrote a struct with no fields, which is a different thing.

`MaybeUninit<T>` joins the grammar as `TypeKind::Uninit`. It is a boundary
concept — an out-parameter whose slot the caller supplies and the callee fills —
and cbindgen already models it as exactly that, so this moves a classification
out of the adapter and into the frontend, per #211. It is also the one foreign
generic that no alias could name, a generic alias being a generic binder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Flat resolves its references and answers by name

Two changes that belong together, because the first is what makes the second
decidable.

**The model is addressed by name, not iterated.** `FlatBuilder` collects and
`build` hands over a `Flat` — `function(name)`, `declared_type(name)`,
`constant(name)`, `element(name)`, plus iterators over each kind. Names are
unique across the whole model, so a name is a complete address, and that is what
every later stage wants: an adapter asks what a declared name *is* rather than
scanning a list. L1 carried this as a checklist bullet; it is really a property
of the model.

Two types rather than one, because a half-built model should not be the same type
as a resolved one — `Source::builder()` sets the precedent.

**References resolve at parse time.** A third pass walks every `TypeRef` — through
`Option`, `Vec`, `&`, `Result`, arrays, callback arguments and generic arguments
alike — and an item naming a type the flat API does not declare becomes
`Element::Unsupported` with `ItemError::UnresolvedType`. Deferred, not fatal, like
every other refusal: an item no binding declares stays harmless.

This is what a marked type alias bought. A dangling name previously surfaced far
downstream as an unresolved *converter*, from whichever adapter happened to look
first — the "one fact, several authorities" #211 exists to end. Note the two
remain distinct: resolution here says a name denotes something, while an
adapter's resolver still decides whether it supplied a converter for it.

A path-qualified name gets its own diagnosis, since `#[prebindgen] pub type
foreign::Option = ..` is not a spelling that exists — marked items live in one
flat namespace of bare names.

Also: `Item::Type` no longer reaches the registry's passthrough. An opaque
declaration states something about the API's surface and is not code to copy into
the binding; its target is routinely crate-private, so re-emitting it would not
compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Close the example flat APIs, and assert they stay closed

Every type a marked signature named had to become a declaration for resolution to
mean anything. Two idioms, chosen by what the type actually is rather than by its
Rust shape:

**A handle gets a marked alias.** `Storage`, the three callback handlers,
`Token`, `TokenGc`, `Summary`, `Archive`, `Report`, `EscapeProbe`,
`StorageError`, and example-flat's `Calculator` move into a private `handles`
module, with `#[prebindgen] pub type X = handles::X;` at the top level. The alias
is transparent, so every signature still says `Storage`. `Error` in both crates
was already an alias and only needed the attribute — which is exactly the shape
zenoh-flat's 26 zenoh re-exports will take.

**A public newtype stays a marked struct.** `Millis`, `Celsius`, `Percent` and
`Label` are not handles: they cross by `convert!`, and covertest-helpers both
constructs them and reads `.0`. Hiding them behind an alias broke that
downstream, which is the useful signal — a type alias names the type, not the
tuple-struct constructor, and the constructor lives in the value namespace where
the struct is defined. In-crate construction of the relocated handlers is
qualified `handles::PayloadHandler(..)` for the same reason.

Marking these as structs rather than aliases matters for a second reason: a
marked struct enters `registry.structs`, and `write.rs` emits `on_struct` for any
declared type there — so marking the *handles* as structs would have changed
generated output. The alias route is invisible to the registry, which is why the
goldens hold.

**And the closure is asserted, not assumed.** covertest-kotlin's build script now
runs `Flat` over both sources and fails if anything is unsupported. It is the
right place: only there do the helper crate's references to perftest-flat's types
resolve, since it cannot mark them itself. Verified by deliberately unmarking
`Storage` — the build fails naming all twelve referencing functions and the fix.

Generation is byte-identical (`examples/regen-check.sh`) and the JVM covertest
passes all 47 sections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Record L0.5 in the stage map

The model is now indexed and resolved, which takes two bullets off L1 — elements
indexed by name, and the entry point that shares one parser — and adds a
prerequisite L0 did not have: the flat API has to be closed for resolution to mean
anything.

Also records what is left open: zenoh-flat and its two consumers are separate
repos whose 28 unmarked types need the same treatment, and `Cow<'_, [u8]>` has no
alias spelling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Take a slice, not a Vec reference, in the resolution pass

`clippy::ptr_arg` under CI's no-default-features run: the pass only mutates
elements in place, so a slice is the honest signature. My local checks used
--all-features only; CI runs three clippy configurations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* An out-parameter is a mode of borrowing, not a type

`TypeKind::Uninit` wrapped a type, but uninitialized-ness is a property of the
**borrow**: my own doc said `MaybeUninit` is "only meaningful behind a `&mut`",
which is the argument against modelling it as a type at all.

So `Ref` carries the mode, and the `MaybeUninit` is absorbed into it:

    Ref { mode: RefMode, inner: Box<TypeRef> }
    enum RefMode { Shared, Exclusive, Out }

`&T`, `&mut T`, `&mut MaybeUninit<T>` — one axis, three values, and `inner` is
always the borrowed *value's* type. One variant fewer than the `mutable` flag plus
a wrapper, and the combinations that mean nothing at a boundary can no longer be
written down: uninitialized storage owned, returned or in a field promises nothing
a destination language can use, and `&MaybeUninit<T>` promises a readable `T` that
may not be one. Both are refused, each naming why.

`Out` rather than `Uninit` because it names the boundary role every destination
language has — C's `T *out` — which is the fact an adapter acts on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address review: transitive closure, generic aliases, goldens, real index

Four findings, all valid; two were mine in this PR.

**Refusal was not transitive.** `resolve_references` snapshotted the initial
declarations and validated everything against that fixed set, so refusing a type
stranded its dependents:

    pub struct Broken { pub field: Missing }   // refused
    pub fn use_broken(value: Broken) {}        // survived anyway

`Flat::resolve` then returned `None` for `use_broken`'s parameter, contradicting
the one invariant the model promises. It now runs to a fixed point: each round
drops the declarations it refused, and stops when a round refuses nothing. Chains
of any length collapse, in either declaration order, because the declared set only
ever shrinks — which is also why it terminates. Regressions cover the direct case
both ways round, a four-link chain both ways round, a sound chain that must be left
alone, and the invariant itself: every `Named` reachable from a surviving element
resolves.

**A generic type alias bypassed the binder refusal.** The `Item::Type` arm built an
`Opaque` without calling `reject_generic_params`, so `pub type Handle<T> =
hidden::Handle<T>;` was accepted as one declaration that `Handle<u8>` then resolved
against — losing exactly the scoped-parameter distinction every other item kind
refuses, and contradicting this PR's own argument that `MaybeUninit` needed grammar
support *because* a generic alias is a binder. Type and const parameters are now
refused; a lifetime binder stays accepted, as on every other kind.

**The aarch64 goldens carried unrelated all-features output.** `git add -A
examples` in the migration commit swept in pre-existing working-tree drift —
`unstable_field`, `calculator_reset`, a non-empty feature guard — which is exactly
the state 95fd753 had reverted, because committed aarch64 goldens represent a plain
build. Restored from the base, and verified: a plain `cargo build --release -p
example-cbindgen` on arm64 reproduces the base files byte-for-byte. CI is x86_64 and
cannot see this pair, so it needed catching by hand. My "byte-identical" claim was
wrong for that reason, not for the model changes.

**`Flat` was not actually indexed.** It stored only a `Vec` and `element()` did
`iter().find`, so every typed accessor and `resolve()` scanned — quadratic once
later stages resolve in a loop, and not the "indexed by name" criterion L0.5
claims. Now a `HashMap<String, usize>` beside the elements: positions, so there is
one copy of each element and source order stays available for iteration. Built
after resolution, since refusing an item changes its kind but never its name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Brings in #221, #231 and #233. Three things worth knowing about the resolution.

**Most conflicts were one change arriving twice.** #227's branch had been rebased
onto #221 before it was squash-merged here, so the squash absorbed #221's diff —
`git diff 225954a 0901651` over jnigen is empty. Merging main then replayed #221 as
its own commit, conflicting with its own absorbed copy in nine files. For each,
`git diff 225954a 989010e` showed this branch had added nothing beyond that copy,
so main's side was taken wholesale: main is #221 plus #231 and #233 on top.

**`Ledger` needed declaring.** #231 added it as an unmarked handle struct, which is
exactly the drift the closure guard exists to catch — and it caught it on its first
merge, naming all four referencing functions. It now takes the same treatment as
every other handle here: the definition sits in the private `handles` module behind
`#[prebindgen] pub type Ledger = handles::Ledger;`. `Report` keeps main's new
`#[derive(Clone)]`, moved onto the definition, since the top-level name is only its
alias.

**Generated artifacts are regenerated**, because the merged tree's committed
copies were a mix of both sides. The Kotlin and Rust output is the union of the two
feature sets, and the boundary ledger is reseeded — `api/core/unfold.rs` 16 → 18, as
#231/#233 added two classification sites there.

Also fixed two strings the `language` → `flat` rename left stale: the ledger's own
drift message and header still named `core::language`.

Note for #231's author: `covertest-kotlin/build.rs` says "`Report` is not `Clone`,
so cloning it here would not compile", while `ext.rs` now derives `Clone` on it and
explains why it must. One of the two comments is stale on main; taken verbatim here
rather than edited, since a merge should not quietly rewrite either side's prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in #234, `Registry::builder().source(dir)`. Two conflicts, both from the
same cause: #234 removed the `Source` bindings from `covertest-kotlin`'s
`main()`, and this branch had added the flat-API closure guard right there,
built from those same bindings.

`FlatBuilder` gains `source_named` — the follow-up #234 flagged, now needed rather
than merely tidy. The guard reads its two directories directly, so the two builders
stay shape-identical and no `Source` survives in that build script:

    Flat::builder()
        .source(perftest_flat::PREBINDGEN_OUT_DIR)
        .source_named(cov_helpers::PREBINDGEN_OUT_DIR, "cov_helpers")
        .build()

That does read each directory twice, once for the guard and once for the registry.
It is a build script and the cost is a second JSONL parse, and it goes away at L1
when the registry consumes `Flat` instead of re-indexing the stream — which is what
the shared shape was for.

`lib.rs`'s conflict was two export lists growing in parallel; both sides' names
belong. The feature-coverage table at the top of covertest's build script now names
`source_named` instead of the `Source::builder().crate_name()` it replaced.

519 tests, 18 doctests, clippy clean on all three configurations, generation
byte-identical, and the JVM covertest still passes all 48 sections — including the
renamed second source, which is the path this merge touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* A prelude, Extern instead of Opaque, and no args

Three things the same question kept surfacing: what does the language know without
being told, and what must it be told?

**Path reduction had one rule and a std special case; now it has one rule.**
`reduce_flat_path` already reduced `crate`/`self` and any source-module prefix — a
path into the flat namespace collapses to its bare name. Bolted on was a
five-entry whitelist of std paths. Naming what that whitelist is removes it: those
are **aliases the language pre-declares**, a prelude in exactly Rust's sense. A
crate need not write `use std::vec::Vec`, and need not write
`#[prebindgen] pub type Vec = std::vec::Vec` either, for the same reason.

So the mechanism is an alias map from path to name, seeded from `PRELUDE` and
extended with every alias the ingested crates declared — because a prelude entry and
a hand-written alias say the same kind of thing. That generalises past std: given
`#[prebindgen] pub type Session = zenoh::Session;`, a signature may now spell
`&zenoh::Session` and reach the declaration. `foreign::Option<u8>` is still not
`Option<u8>`, because the key is the whole path, never a final segment.

`Normalization` holds what to reduce against, replacing the module-gathering loop
`FlatBuilder::build` and `Registry::from_items` each wrote separately — they cannot
normalize differently now.

Two traps found on the way. A marked alias must be excluded from the normalization
it defines, or `pub type Duration = std::time::Duration` becomes
`pub type Duration = Duration`. And the prelude's entries are *generic*, so an early
"reduce only without type arguments" guard broke `std::vec::Vec<Foo>`; the guard was
also unnecessary, since a full-path key cannot collide.

`mem::MaybeUninit` joining the prelude is a bug fix. It was a grammar builtin that
was **not** reducible, so it worked only because perftest-flat happens to `use` it;
written `&mut std::mem::MaybeUninit<Payload>` it became an unresolvable nominal type
and silently refused the item — and `maybe_uninit_inner`'s comment claimed
normalization had already reduced it. One test row per prelude entry now pins both
spellings to the same kind, which is how that class of drift gets caught.

**`Opaque` becomes `Extern`, and carries what it points at.** It was never only
handles: `pub type Duration = std::time::Duration` crosses by value through a
`convert!`, erased to an integer. What the frontend knows is narrower and truer —
this name is in the flat API and its contents are not modelled — and the adapter
decides the rest. `target` is now a modelled fact, so an adapter can recognise
`std::time::Duration` without taking syntax apart, and reduction uses it.

Deliberately not classified as std-vs-foreign: `pub type Error = zenoh::Error` IS
`Box<dyn std::error::Error + Send + Sync>`, so std-ness is a property of the
spelling, not the type. A rule keyed on the path root would answer differently for
one type depending on who aliased it.

**`args` is gone from `Named`.** A reference is a name. Nothing could read retained
arguments: a surviving reference resolves to a declared type, and no declaration
takes type parameters, so `Foo<u8>` against a declared `Foo` would not compile in
the source crate. They are still lowered, so a bad type inside one is diagnosed —
the dropped test row asserted a shape real source cannot produce.

The boundary ledger gains one site in `types_util` for reading an alias's target;
L2 reclaims it when the frontend owns normalization outright.

Generation is byte-identical and the JVM covertest passes all 48 sections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Box the array extent, the size outlier among the kinds

`clippy::large_enum_variant` under `-D warnings`: an `ArrayExtent` carries an
`Origin` over its length expression, so `Array` towered over the second-largest
variant once `Named` lost `args`. The lint compares those two, which is why
shrinking one variant surfaced another's size.

Boxed rather than allowed — an array is the rare kind, the same trade-off
`Unsupported::error` already makes for the same reason.

My local clippy runs missed it because they omitted `-- -D warnings`, so it was a
warning my filter did not match. CI passes that flag in all three configurations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address review: an alias key is a whole type, and never shadows the grammar

`path_key` dropped **all** generic arguments, so `type Bytes = std::vec::Vec<u8>`
keyed on `std::vec::Vec` — overwriting the prelude entry, since the alias pass runs
after the seeding. Reduction then swapped the ident and kept the use site's
arguments, so `Vec<String>` became `Bytes<String>`, `Named` discarded the argument,
and an unrelated parameter stopped being a `Sequence`. Any concrete alias could do
this to any prelude entry.

The root cause is a constraint I had not stated: normalization decides which
spellings denote **one type** (issue #95, "the canonical flat-namespace spelling"),
so it may choose a canonical spelling but must never change what a type *means*.
`zenoh::Session` → `Session` preserves the kind. `Vec<u8>` → `Bytes` turns a
sequence into an extern — retyping, not canonicalizing.

Naming what the two kinds of alias are makes the fix structural rather than a
patch. They **partition** the targets, because a target either has a grammar meaning
or it does not:

* the prelude, over targets the grammar models. Each names a **constructor**, so
  arguments are ignored when matching and preserved when rewriting —
  `std::vec::Vec<Foo>` is `Vec<Foo>`.
* a crate's aliases, over targets it does not. Each names one **complete type**, so
  the key keeps type arguments (lifetimes still dropped, since a lifetime is
  spelling) and a match replaces the whole type — an alias name carries no arguments
  of its own.

So an alias to something the grammar already models is not a reduction rule: the
prelude owns that path. `type Bytes = Vec<u8>` stays a perfectly good name for an
`Extern` — a bare path is never reduced, so `Bytes` resolves — while `Vec<u8>` keeps
meaning a sequence and `Vec<String>` is untouched.

Duplicate targets now resolve deterministically: first declaration wins, rather than
last-in-stream.

Two regressions, both verified to fail against the old behaviour before being kept:
the reported case verbatim, and two concrete aliases over one foreign constructor
staying distinct. The partition is documented where each half lives — the
equivalence rule list and `Extern`'s own doc, including the asymmetry that an alias
is an `Extern` always but a reduction rule only sometimes (`type Error = Box<dyn
Error>` has no rule at all).

Generation byte-identical, ledger unmoved, JVM covertest 48 sections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* An alias is a one-way road, not an equivalence

The review found that `alias_key` kept only `GenericArgument::Type`, so
`type Small = zenoh::Wrap<4>` and `type Big = zenoh::Wrap<8>` still collided on their
const arguments. Retaining every non-lifetime argument would fix that instance, but
the key shape was never the real problem.

Normalization decides which spellings denote **one type**. An alias does not create
such a spelling — it brings a foreign type *into* the flat API under a new name. That
is a one-way road: the name is thereafter the only way to spell the type here, and
`zenoh::Session` in a signature stays refused even when `type Session =
zenoh::Session` is declared. The diagnosis already said exactly that — "Give the type
a name here with `#[prebindgen] pub type <Name> = ..;` and refer to that" — so alias
reduction was weakening a rule the language already had.

Treating it as an equivalence is a category error, and the two reported bugs are
symptoms of it: `Vec<u8>` ≡ `Bytes` turns a sequence into an extern, and once one
path can stand for two types, key shape decides which — arguments, const arguments,
associated bindings, each a new way to collide. Removing the equivalence makes that
class unreachable rather than patched.

So a crate's `pub type` is a declaration only, and the prelude alone reduces:
`std::vec::Vec<Foo>` is `Vec<Foo>`, because those *are* one type. The prelude and a
crate's aliases stop being "two kinds of alias" needing a partition — different
mechanisms with different jobs, which is the simpler answer to how they relate.

Net −133 lines: `alias_key`, `type_args`, the alias map, the alias-collection pass,
the first-declaration-wins tie-break, and the circularity guard that stopped an alias
rewriting its own target all go. The boundary ledger returns to 205 — the site the
previous commit added was reading an alias target, and nothing does that now.

Nothing real depended on it: no marked signature in zenoh-flat or the examples spells
a qualified alias target. Verified byte-identical generation and 48 JVM sections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`zenoh-flat`'s `zbytes_to_bytes(z: &ZBytes) -> Cow<'_, [u8]>` was refused by the
closed flat API, so it would vanish when that crate migrates.

The cause was an assumption in `lower_path`'s guard — "a builtin generic takes types
only; a lifetime argument on one is not a shape this language has" — which skips the
whole builtin match when any lifetime argument is present. `Cow` is the counterexample
it did not anticipate: a builtin generic whose own signature includes a lifetime. So
`Cow<'_, [u8]>` fell through to an undeclared nominal `Cow` and the item was refused.

**A `Cow` carries nothing a destination language can see, and both adapters already
say so in code.** cbindgen: "`Cow<'_, [T]>` → `T_wire* + size_t`. The C side receives
an owned malloc'd copy, just like `Vec<T>` outputs", and `type_contains_vec` groups
the two. jnigen: `env.byte_array_from_slice(&v)` — `&Cow<[u8]>` derefs to `&[u8]`, so
there is no Cow-specific conversion at all — yielding Kotlin `ByteArray`, exactly what
`Vec<u8>` yields.

So `Cow<'_, T>` classifies as `T`'s own kind, the `Box<T>` treatment, and no
`TypeKind` variant is added: the semantic surface says nothing about a fact no
destination acts on. What codegen genuinely needs is the *spelling* — jnigen rewrites
its generated fn's param type to `::std::borrow::Cow<'_, [u8]>` because "the param
type must be resolvable without imports" — and spelling already travels in `origin`.
Classify off `kind`, spell off `origin`, with both adapters' existing behaviour now
predicted by the classification instead of special-cased.

Transparent for any target, as `Box` is. Whether a `Cow` can actually cross stays the
adapter's call, and both already restrict — cbindgen to scalar slices, jnigen to
`[u8]` — refusing the rest with their own diagnostics.

`std::borrow::Cow` joins the prelude, for the reason every entry is there: a name no
source has to import. It also stops the frontend being *stricter* than the adapters,
which tail-match the last path segment and so accept a qualified spelling — the
cbindgen fixture `cow_u8_returns_scalar_array` writes exactly that, which is the proof
the qualified form occurs.

Verified the three new rows fail against the old guard before keeping them. Generation
byte-identical, ledger unmoved, 48 JVM sections. zenoh-flat is a separate repo, so
`zbytes_to_bytes` is covered by an acceptance row rather than by a build.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
milyin and others added 6 commits July 30, 2026 22:29
* Make every test fixture self-sufficient

Preparation for L1, where `Registry` consumes `Flat` and an item naming a type
the flat API does not declare stops being ingested. 167 of 524 tests held such an
item; this makes them all declare what they name, verified against a temporary
`#[cfg(test)]` check inside `from_items` that the next commit deletes.

**`declare_referenced`** appends a marked alias for every nominal type a stream
names but never declares, to a fixed point. Most fixtures are *about* a plan shape
or a converter, and a handle declaration is noise in them —
`reg_with(&["fn get(s: &Storage) -> Payload"])` is testing an unfold plan, not what
`Storage` is. Declaring those as `Extern`s is what a real source crate does for a
foreign handle, and it is inert either way: a type alias lands in no registry map.
`reg_with` now parses `syn::Item`, so a fixture *can* declare its own types when
that is the subject.

Four things the helper cannot cover, each a real correction:

**`std::time::Duration`** was spelled path-qualified in 15 places. A qualified name
can never be a flat-API name, so those fixtures now declare `Duration` and spell it
bare — the shape a real source crate uses. That moves the `TypeKey`, so the
matching `convert!` and two generated-name assertions move with it.

**Two array-length "qualification" tests** asserted that `Holder::N` and
`array_len()` lengths get qualified. The subgrammar was narrowed to "an integer
literal or the bare name of a marked const" in #212, so neither can reach an
adapter any more; they survived only because `from_items` never validated lengths.
Reduced to the form that can. (jnigen's qualifier still handles the dead shapes —
removing that is L4's business.)

**Three array-length rejection tests** move to `flat/tests/acceptance.rs`, where
the subgrammar lives. They cover the dangerous family — `const {}`, `match`, `if
let`, all of which bind a local that could shadow a marked item — and belong with
the classification, not with jnigen.

**Two registry tests are removed**, not edited: both assert that ingestion does
*not* validate signatures, which is precisely what the next commit reverses. Their
replacement lands there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Registry consumes Flat

L1 of #229. `Registry::from_items` indexed the raw item stream itself, so the
registry and `Flat` were two readings of one source that could disagree. The
registry is now a **projection** of the model: `from_items` is
`Flat::builder().items(..).build()` + `from_flat`, and the maps are arranged from
elements the frontend already classified.

The `Flat` is **held**, not discarded — `registry.flat()`. That is what makes the
projection framing real rather than a slogan, and it is how L2–L4 reach the model:
an adapter already has the registry.

The maps stay owned rather than becoming live queries, because they are a
projection *plus* synthesis: `resolve()` injects adapter-declared binding-local fns
straight into `functions`.

Projection rules worth stating, because two are asymmetries:

* an unnamed `const _` — each source's injected `konst` guard — is the whole of
  `passthrough` now. The proc-macro refuses to mark a `use`/`mod`/`macro_rules!`,
  so nothing else ever reached it.
* an `Extern` lands in **no** map. A type alias was already a no-op here, and
  keeping it that way is what holds generation byte-identical. It is reachable
  through `flat()` for the stages that will want it.

**Ingestion now checks that the flat API is expressible.** A `self` receiver, an
`async fn`, a generic binder, a type form outside the grammar, or a reference to a
type the flat API does not declare fails the build — reporting **all** offenders at
once, so a source crate that needs migrating sees one list rather than one rebuild
per item. An opt-out for deliberately-unsupported elements is filed separately.

That makes three registry guards unreachable, so they and their `ScanError`
variants are deleted: `UnsupportedReceiver`, `UnsupportedParamPattern`,
`DisallowedImplTrait`. The frontend's diagnosis is strictly richer — it names the
parameter the bad type sits on. `index_item`, `check_no_duplicate` and
`first_seen_loc` go with them: `Flat` owns both indexing and duplicate detection.

`ParseError::DuplicateName` gains the two crate names, so the one authority
produces the message that names both colliding sources.

covertest-kotlin's hand-rolled closure assertion is removed — it was a stopgap for
exactly this stage, and the registry now raises the same thing.

Generation is byte-identical, the JVM covertest passes all 48 sections, and the
boundary ledger drops to 204 (the deleted `impl Trait` classifier).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Record L1 in the stage map

Ticks L1 and records the decision that supersedes its original wording: an item
the language cannot express fails ingestion rather than staying inert until
declared. Also notes the measured fixture cost and what is still open — zenoh-flat's
26 unmarked aliases, which block its two consumers until marked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Point the L1 note at the filed opt-out issue

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address review: validate the one input that bypasses Flat

**The diagnostics regression (review 2.1).** `resolve()` inserts
`adapter.local_functions()` straight into `self.functions`, so a `sig!(..)`
written by hand in a build script never touches `Flat`. Deleting
`scan_fn_signature`'s receiver and pattern guards therefore did not merely move
those checks — it removed them for that input. `sig!((self, x: u32) -> Ret)` would
`continue` here, `continue` again in `fn_plan`, and drop the parameter silently;
the user would meet it as an arity mismatch out of rustc on generated code.

Fixed where the reviewer suggested, at synthesis rather than back in
`scan_fn_signature`: `Flat::check_signature` runs the frontend's own `lower_fn`
over a local fn, so the grammar stays decided in one place and the check sits on
the one input that bypasses it. Grammar only — whether a local fn's types are
*declared* is a whole-model question, and a binding-local fn may legitimately name
types the source crate never did. Both halves are tested.

The two "cannot reach here" comments now say why, naming both paths.

**The report loses the crate (review 1.2).** A captured path is crate-relative,
so two offenders read `src/lib.rs:0:0` and the location alone cannot say which
crate to fix — exactly why this PR added crate names to duplicate-name
diagnostics. `NotExpressible` now renders `in crate `x`` using the same
`in_crate` phrasing, with a two-source test whose offenders share a file path.
The trailing newline is gone with it. `DeclaredNotFound` and
`QualifiedDeclaredTypes` have the same trailing-newline shape and are left alone
as pre-existing.

**`Registry::default()` (review 2.2).** A registry built that way projects
nothing, so `flat()` would hand a later stage an empty model claiming to be its
source. The `Default` impl becomes `pub(crate) fn empty()`: outside the crate the
entry points are `from_items`, `from_flat` and `builder`, each with a model behind
it. Nothing required the bound; in-tree fixtures were the only callers.

**Flat's docs promised the opposite (review 1.1).** They said an `Unsupported`
element stays inert until an adapter declares it, which this PR supersedes.
Rewritten around the actual split — **parsing diagnoses, ingestion raises** —
which is what lets one model serve both a consumer inspecting what a crate marked
and a binding that must be built against a model read in full. Four sites,
including `Element::Unsupported` and `Flat::unsupported`.

**Untested behaviour changes (reviews 1.3, 2.3).** `from_flat` had no direct test;
everything reached it through `from_items`, which cannot tell "the projection is
right" from "parser and projection are wrong in matching ways". Added one
asserting every element kind's destination, that the model is kept, and — the
change the reviewer caught — that an `Extern` now records an origin where the old
`syn::Item::Type` no-op recorded none, so a helper-crate alias qualifies against
the helper crate instead of the default module.

Generation byte-identical, 524 + 452 tests, covertest 48 sections. The local-fn
guard was checked against its own removal and fails as it should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* The type table carries Flat's reading of each type

L1 made `Registry` a projection of `Flat` for the item maps. The **type table** —
what generation actually runs on — still threw the frontend's work away, keying
cells by a normalized `syn::Type` with the classification deleted. That deletion
is why `types_util` exports `is_option_type` / `option_inner_type` /
`result_parts` / `bare_path_ident`: 144 uses outside that one file, all
recomputing what `Flat` decided.

`TypeEntry` itself had nothing to reuse and is unchanged in spirit: `destination`,
`function`, `pre_stages`, `niches`, `metadata` are the adapter's answer, not the
source's meaning. The reuse is one level up, in the cell the entry hangs off.

    input_types: HashMap<TypeKey, TypeCell<M>>

    TypeCell { subject: TypeSubject, root: bool, entry: Option<TypeEntry<M>> }
    TypeSubject::Source(TypeRef) | TypeSubject::Adapter(syn::Type)

An enum rather than an `Option<TypeRef>` beside a location, because a type the
flat API contains **is** a `TypeRef` — classification and origin together — and a
type only the binding authored has no reading and no source location. That is a
fact about it, not information that went missing.

So `type_locations` is deleted: `TypeRef.origin.location` is it. The old map was
worse than duplicated, it was circular — the declared-type path read a key's
location back out of the map it was about to write, falling back to
`SourceLocation::default()`. With one origin per cell the whole `loc` parameter
threads out of `ensure_entry`, `scan_fn_signature`, `scan_struct`, `scan_enum`,
`register_type_*`, `require_input` and `require_output`.

**The readings come from the model, not from lowering twice.** `Flat::type_refs`
walks every type the API mentions — the new accessor, distinct from `types()`,
which is every type it *declares* — and `from_flat` indexes it before anything is
scanned. `ensure_entry` then looks a key up. Keying by type rather than threading
positionally is what makes it right for generics: `TypeId` carries no arguments,
so `MyBox<Foo>` has no `Foo` child in its `kind`, yet the registry's walk emits a
`Foo` sub-key — which finds its reading from wherever else `Foo` appears.

`first_unresolved`'s per-element slot enumeration became `element_type_refs`, so
the slots are listed once and `type_refs` cannot drift from the resolver.

**`required` stops being stored.** It was one name over three storages, and two
facts: *is a root* (a scan fact) and *is reachable from a root through the
adapter's `subs`* (a derivation). The old code wrote the derived answer back into
`TypeEntry::required` **and** `required_*_scan`, which already held the root fact.
Now the cell keeps `root` and `resolve::required_set` returns the reachable set
for `final_invariant_check` to consume. Gone: `required_inputs_scan`,
`required_outputs_scan`, `TypeEntry::required`, `propagate_required`,
`set_required`, `is_required_resolved`, `mark_and_get_subs`,
`is_required_*_at_scan`, `lookup_slot`.

`root` stays a field rather than folding into `TypeSubject` because the axes are
independent — all four combinations occur. `Source + root: false` is the bulk of
the table (every nested position, every field type), and `Adapter + root: true` is
what `required_output_types` is for.

`immediate_edges` reads a declared type's fields off the element
(`flat.declared_type`) instead of `syn::Fields::Named`, which silently skipped
positional fields. Same edge set today — a tuple struct is an `Extern` and
declares none — without the asymmetry. Its fallout in tests was a fixture that
hand-inserted into `reg.structs` while leaving `flat` empty; it now drives the
real scan, which is the state the pipeline can actually produce.

**Measured**: 3 `Adapter` cells out of 342 across the four examples —
`Option<Summary>` and `Result<Summary, String>` (shapes the adapter composes) and
`MaybeUninit<Payload>`. The last is the evidence for a deferral: flat absorbs
`MaybeUninit` into `RefMode::Out`, so that bare node exists only in the registry's
syntactic walk — which is why `immediate_subtype_positions` is not yet replaced by
a `TypeKind`-children walk. The `Option` earns its keep.

The ledger does not move. This makes the classification available; taking callers
off raw syntax is L2's own work.

Generation is byte-identical, 523 + 451 tests pass, covertest-kotlin runs all 48
sections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* `const _` is a Guard, not a Constant

The injected feature check was modelled as a `Constant` whose name happens to be
`_`, and every consumer that must not treat it as API re-checked that sentinel.
Five sites did, across four files.

Two facts make the sentinel wrong rather than untidy:

**The guard is not a captured item.** `Source`'s cfg filter *synthesizes* it
(`api/batching/cfg_filter.rs:143`) — one per ingested crate, asserting that
crate's `FEATURES` match what the build script asked for. Nothing in the source
crate marked it, so it was never part of the flat API, which is the set of things
a `#[prebindgen]` crate declares.

**Four of the five checks were already dead.** Once L1 routed unnamed consts away
from `consts`, `write.rs`'s const gate, the skipped-const warning, and both
`on_const` implementations guarded a state the pipeline could no longer produce.
That is the failure mode a sentinel invites, and it had already happened.

So:

    Element::Guard(Guard { origin: Origin<syn::ItemConst> })

Named for what it **is** — a compile-time assertion protecting the generated file
— not for what a consumer does with it. `Element` classifies; `Passthrough` would
name an emission strategy, and that variant was deliberately deleted earlier in
this program.

Recognised by **shape**, not provenance: a constant with no name has no address,
so nothing can declare it, reference it, or emit it as an alias. That is the
property that makes it infrastructure and it holds whoever wrote it — so no new
ingestion channel is needed and today's behaviour is preserved exactly.

It carries **no `TypeRef`**. The item is emitted verbatim, so what its types mean
is the consumer crate's business. Today the guard's `()` is lowered and does
participate in `first_unresolved`, so a guard naming an undeclared type would turn
the whole element `Unsupported` and — post-L1 — fail the build. `()` is `Unit`, so
that never bit; dropping the slot removes the coupling.

`Element::name` loses its `.filter(|id| *id != "_")`, which existed for this alone.
`Registry::passthrough` becomes `guards: Vec<Guard>` — the bucket's one occupant
now names it. Emission is unmoved: last in `write_rust`, in stream order.

One `"_"` comparison stays, in `flat/mod.rs`'s Pass 1: the `ConstIndex` an array
extent resolves against is built before Pass 2 classifies anything, so it has only
raw items to filter. It is the one site that cannot read a classification, and now
says so.

The module doc's "no verbatim passthrough" claim is **amended, not reversed**: no
*marked* item passes through, and the one item that does was never marked.

Generation byte-identical (the two aarch64 goldens drift identically to the base
branch — the known `--features unstable` mismatch), 524 + 452 tests, covertest
48 sections. Both new tests were checked against a reverted classification and
fail as they should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address review: state the contract the classifier actually enforces

**The docs overclaimed (review 1).** `lower_item` classifies *any* anonymous const
as a `Guard` — a hand-fed `FlatBuilder` item, a user-written `#[prebindgen] const
_: ..` — but the docs said "prebindgen's own injected checks" and "one feature
guard per ingested source crate". Both cardinality claims are wrong, and I checked
rather than assumed:

* `enable_feature_filtering(None)` leaves `features_constant: None`, so
  `build_cfg_filter` skips the guard entirely — **zero**;
* `items_all` / `items_in_groups` / `items_except_groups` each build a *fresh*
  `CfgFilter` with `prelude_emitted: false`, so composing two iterators from one
  `Source` yields **two** guards from one crate.

Keeping the shape rule, which was the deliberate choice, and making the docs say
what it means: a `Guard` is an **anonymous const**, defined by having no address
rather than by who produced it; the feature check is documented as today's
producer rather than the definition; cardinality is **zero or more**. Six sites,
including `Guard`'s own doc, `Flat::guards`, `Registry::guards` and `write.rs`.

**Emission was untested (review 2).** `a_guard_never_reaches_the_const_surface`
proves the maps are separate but never calls `write_rust`, so nothing caught a
change that keeps `Registry::guards` populated and then drops or re-gates it on
the way out. `guards_emit_ungated_and_in_stream_order` declares an *empty*
`declared_consts()` gate with one named const and two distinguishable guards
straddling it, and asserts the named const is gated out while both guards emit in
order. Checked against both failure modes — emitting none, and emitting reversed —
and it fails on each.

**The doc contradiction (review 3).** `from_items` listed `guards` among the maps
and then said undeclared items "never emit", which is the opposite of what a guard
does. Now says an *API* item behaves that way and names `guards` as the exception
that is outside the gate because it has no name to declare. The core module
overview's stale "passthrough items" goes with it.

Also fixed three unresolved intra-doc links introduced across this stack (one
here, two in the type-cell commit) — no CI job gates on them, so they are fixed at
the tip rather than by another rebase. Warnings 17 → 16 against the L1 baseline.

529 + 457 tests, clippy clean in three configs, generation byte-identical,
covertest 48 sections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Flat is the only index; Registry stops keeping a second one

`Registry` held five maps — `functions`, `structs`, `enums`, `consts`, `guards` —
plus `item_origins` and `source_modules`. Every one was built in `from_flat` by
walking `flat.elements()`, and every one duplicated something `Flat` already had,
indexed the same way. L1 made the registry a projection of the model; a projection
that copies is still two stores that can disagree.

All seven are deleted. `from_flat` is now the expressibility check, the type-ref
index, and storing the model — nothing else.

Two facts found by measuring the call sites first:

* **The `SourceLocation` half of every entry was dead.** All 44 `.get()` sites
  destructured `(item, _)`; every `values()` and index site bound `_loc`. Nothing
  had read it since L1 moved locations onto elements.
* **`guards` had one reader** and was already `Vec<flat::Guard>` — the flat type,
  copied out of the model verbatim.

`Flat` grows what the maps were providing, beside its existing typed accessors:
`struct_type`, `enum_item` (either enum shape — the merge the old `enums` map made,
which 30 adapter reads depend on), and `source_modules`. `Registry` keeps
`origin_module`, `default_module`, `all_source_modules` and `named_item_idents` as
methods — they are questions, not storage — now answered off `flat`. **No mirrored
accessors on `Registry`**: one door, so there are not two interfaces to keep in
agreement.

**Binding-local fns move into the model.** They were the one population `Flat` did
not have — a `sig!(..)` is written in a build script and was inserted straight into
`registry.functions` — so deleting that map would have left `flat.function()`
incomplete and "one index" a lie. `check_signature` already lowered one through
`lower_fn` and discarded it; it returns the `Function` now, and a `pub(crate)`
`add_local_function` admits it with the adapter's origin crate stamped where
`origin_module` already looks. The public surface does not grow.

Three invariants would have moved generated output silently. Each is now tested,
and each test was checked against its own violation:

* `named_item_idents` must keep excluding `Extern`. Its caller decides which names
  generated Rust qualifies, so including an alias would move output.
* `source_modules` must not see binding-local fns — it decides `default_module`,
  which is what an unqualified reference resolves against. Fixed by construction:
  `Flat` freezes it in `build()` from the captured stream, and
  `add_local_function` does not touch it.
* `item_origins` must keep seeing them — the mirror of the above, and what
  qualifies a local fn's generated call.

**Ledger 204 → 202**, the first movement in this program: `accessor_signature` and
`accessor_consumes` peel a borrow by reading `TypeKind::Ref` instead of matching
`syn::Type::Reference`. `ctor_signature` and two return-type walks likewise read
`params`/`ret` off the element rather than re-deriving them from the signature —
which also drops three hand-rolled copies of "an elided return is `()`", a fact the
model states once.

Generation byte-identical **with `cargo clean -p example-cbindgen -p example-flat`
first** — the check only regenerates what cargo decides to rebuild, so a cached run
proves nothing. 589 + 517 tests, covertest 48 sections, clippy clean in three
configs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Spell the negated lookups as `is_none`

`!x.is_some()` from the mechanical rewrite. `clippy::nonminimal_bool` on the MSRV
toolchain rejects it; the newer clippy I had been checking with does not, so this
reached CI.

The gap was in the verification, not the code: CI's clippy step is
`--no-default-features --all-features` **together** and runs on 1.85.0, and I had
been running the two flags separately on nightly. `cargo +1.85 clippy --all-targets
--no-default-features --all-features -- --deny warnings` reproduces it exactly and
is now clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* A lookup takes the name the caller already holds

Making `Flat` the only index left every lookup spelling its argument
`&x.to_string()` — 73 call sites — because the accessors take `&str` while callers
hold a `syn::Ident`.

One fact from `proc_macro2` decides the fix, and it is the opposite of what the
obvious move suggests: **the allocation cannot be removed, only moved.**
`impl Hash for Ident` hashes via `self.to_string()`, so re-keying `by_name` as
`HashMap<syn::Ident, _>` would allocate on every lookup *and* every insert, and
would make the `&str` callers start paying too. `Ident` has no `Borrow<str>` and no
`as_str()`, so no borrow-based path exists either.

So this is call-site noise, not cost, and it belongs in the API:

    pub trait Name: sealed::Sealed { fn as_name(&self) -> Cow<'_, str>; }

    impl Name for str        // Borrowed — free
    impl Name for String     // Borrowed — free
    impl Name for syn::Ident // Owned    — the allocation, moved inside
    impl<T: ?Sized + Name> Name for &T

The six accessors — `element`, `function`, `declared_type`, `constant`,
`struct_type`, `enum_item` — take `&N: Name + ?Sized`.

`Cow` rather than a simpler `impl Display` + `format!` because two callers must stay
allocation-free: `immediate_edges` runs per type-graph edge across both the scan and
the resolver's BFS, and `Flat::resolve` runs per reference. Both hold a `String` or
`&str` and keep `Cow::Borrowed`.

The blanket `&T` impl is what let the migration be one mechanical rule
(`&X.to_string()` → `&X`): without it, the sites where `X` is already a reference
would have produced `&&Ident`.

Sealed, so the one new public name cannot grow a second meaning from outside. That
is the trade against the alternative — a `*_by_name` twin for each accessor, twelve
names instead of seven, two spellings per concept to keep in agreement.

Signature change only: no generated byte and no test assertion moves. The call sites
lose 22 net lines; `flat/mod.rs` is the only file that gains any. Verified with
`regen-check` after `cargo clean -p example-cbindgen -p example-flat`, `cargo +1.85
clippy --all-targets --no-default-features --all-features`, 589 + 517 tests, and
covertest's 48 sections. A doc-test on `Name` pins that both spellings reach the
same element.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address review: docs, one alias answer, and no self-inflicted expects

**1. Stranded docs.** `declared_type_idents` landed between `named_item_idents`'
doc block and its signature, so a private helper carried three stacked blocks while
two public methods carried none — and `origin_module`'s doc had already been
stranded there before this branch. Each doc now sits on its own method. The
inherited text also still described `item_origins`, which this PR deletes; that
sentence is gone, and the alias exclusion is stated where the arm performing it can
be seen.

**2. Two sibling checks disagreed about an alias.** `scan_declared_items`'
path-qualified warning became `declared_type(..).is_some()`, which answers `Some`
for an `Extern`; the `ignored_types` check sixty lines down kept
`struct_type(..) || enum_item(..)`, which does not. Both were `structs || enums` on
the base, so I had changed one and not the other.

Chosen answer: **an alias does not count**, restoring both to the base's behaviour.
Firing is arguably more correct — an alias *is* a captured item declaring that name
— but this PR claims to move no behaviour, and that claim is what makes
`regen-check` meaningful as its proof. A warning that starts firing is still a
change, and it belongs in a PR that argues for it and tests it.

Both sites now share `declares_type_body`, so they cannot drift apart again.

**3. Three self-inflicted `expect`s.** Each loop collected `Vec<&syn::Ident>`,
sorted, then looked every name back up — manufacturing an infallible lookup the type
system could not see was infallible. They hold the elements instead
(`Vec<&Function>` / `Vec<&Constant>`, sorted by `name`), which deletes the `expect`,
a second hash per iteration, and a `to_string()` per iteration. Ordering is
unchanged: `Ident: Ord` is the string order. This restores the standing rule that
the working path carries no `expect`.

**4. `__f` / `__s` / `__c` closure bindings**, 29 sites, artifacts of the mechanical
rewrite dodging an outer `f`/`s`. Now `func` / `st` / `konst`. The 13
`__e`/`__v`/`__x` are pre-existing and left alone.

**5. `check_signature` → `lower_signature`.** It returns the lowered `Function` and
the caller keeps it; the name should say lowering-that-validates rather than
checking.

Re-applied on top of the #244 merge rather than rebased: #244 rewrote most of the
same lines, so replaying produced 13 conflicts against a branch whose content I
could reproduce exactly. `declares_type_body` needs no `to_string()` here, since
`Name` landed with #244.

Byte-identical generation (after `cargo clean -p example-cbindgen -p example-flat`),
589 + 517 tests, covertest 48 sections, `cargo +1.85 clippy --all-targets
--no-default-features --all-features` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* An alias counts as a declaration of its name

The follow-up #243's review asked for: there, both type-diagnostic sites were
restored to `structs || enums` because that PR's claim was that it moved no
behaviour. This is the change on its merits.

Two sites ask "does the source declare a type under this name?":

* the **path-qualified** heuristic — `ptr_class!(foreign::Handle)` warns "a
  captured item `Handle` exists — declare it by its bare name";
* the **ignored-type** check — `ignore_types(Handle)` warns "not found among
  `#[prebindgen]` items".

Both answered "no" for an alias, and both were wrong to. `#[prebindgen] pub type
Handle = ..` **is** a declaration of the name `Handle`, and an adapter may declare
it bare — that lands in the no-indexed-body branch, which is exactly what
`ptr_class(ZKeyExpr<'static>)` relies on. So the first suppressed a fix-it that
would have worked, and the second called a captured item missing.

The exclusion was never a decision. It is an artefact of where the answer used to
come from: the pre-`Flat` code asked the `structs`/`enums` maps, which never held
an alias because the registry had no map for one. #243 moved the lookup to the
model and the artefact became visible.

`declares_type_body` → `declares_type`, and it is `flat.declared_type(..).is_some()`.

**`declared_type_idents` deliberately keeps excluding aliases.** It is the sibling
that looks like it should change and must not: it feeds *"skipping undeclared
`#[prebindgen]` struct/enum"*, which asks what an adapter left unclaimed and names
a kind an alias is not. Warning about unclaimed aliases may be worth doing, but it
needs its own message and is a different question. Both halves are pinned by the
test, and both were checked against their own violation.

**Nothing in-tree exercises this.** The four example crates emit 251 of these
warnings and the set is byte-identical before and after — measured, not assumed.
So the tests are the only proof, and they construct the case directly rather than
leaning on the examples.

The warning *text* is `cargo:warning=` on stdout and is not captured; what the
second test pins is that an alias reaches both sites through `scan_declared`
without tripping the `QualifiedDeclaredTypes` hard error. Said plainly rather than
claimed as coverage it does not have.

591 + 519 tests, generation byte-identical after a forced rebuild, covertest 48
sections, MSRV clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Name the body-only helper for its population

`declared_type_idents` read as the iterator form of `declares_type`, which it is
not: the predicate counts every declared type including aliases, the iterator
excludes them. Review's point — the pairing invites exactly the accidental
widening the rest of this PR documents against.

`struct_enum_idents` names the population instead, and matches word-for-word the
warning it feeds ("skipping undeclared `#[prebindgen]` struct/enum"), so the
reason for the exclusion is visible at the call site. The doc says outright that
it is not the iterator form of the predicate.

The existing test already calls the helper directly, so the clearer name is pinned
too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Point the JNI docs at the model, not the deleted maps

Five comments still named maps this PR removes, so the documentation described an
architecture that no longer exists:

* `jni/classify.rs` — "`registry.structs` probes" → `registry.flat()` type probes,
  which is what `type_kind` actually does
* `jni/mod.rs` ×2 — `registry.functions[ident]` → `registry.flat().function(ident)`,
  matching the lookup `kotlin_emit`/`symbols` perform on a `FunctionEntry`
* `jni/emit/struct_out.rs` — "`registry.structs`" → the parsed model; the claim
  "populated before `resolve`" still holds, the model more plainly than the maps did
* `jni/trait_impl.rs` — the fourth the review did not name: it explained the
  default-module fallback in terms of "items `item_origins` never sees", and
  `item_origins` is gone. Restated as what the fallback now turns on — an element
  whose location carries no crate name.

Each new claim was checked against the code rather than assumed: `classify.rs:67`
probes `flat().struct_type`, the `FunctionEntry` lookups are
`flat().function(&entry.rust_ident)`, and `struct_out` reaches the model through
`ext.type_kind` (indirect, as the original comment also was).

Docs only — no code, no behaviour. 591 tests, generation byte-identical after a
forced rebuild, MSRV clippy clean, doc-link warnings unchanged at 16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Flat owns the type index too

`Registry::type_refs` was a `HashMap<TypeKey, TypeRef>` built in `from_flat` by
walking `flat.type_refs()`, with one consumer: `ensure_entry`, deciding whether a
cell's subject is the frontend's reading or an adapter-authored type. An index
over `Flat`'s own content, held outside `Flat` — and `Flat::type_refs()` had no
other caller, so the public iterator existed only to feed it. #243 made `Flat` the
only *item* index; this was the last one left.

`Flat` gains `by_type` and `type_ref(&syn::Type) -> Option<&TypeRef>`, so
**`from_flat` collapses to what it always should have been**: check
expressibility, store the model. Everything still in `Registry` is now genuinely
its own — the two type tables and the five adapter-declared plan maps.

**A binding-local fn's parameter types are now indexed**, which the old ordering
got wrong: the index was built in `from_flat`, local fns are inserted later by
`resolve`, so their types missed it and their cells came out `Adapter` — "no
frontend reading" — though `lower_signature` had produced `TypeRef`s for them.
`add_local_function` feeds the index. Deliberately unlike `source_modules`, which
stays frozen because it decides `default_module` and would change how *captured*
items are qualified; this only makes a cell tell the truth about a reading that
already exists.

**One definition of canonical spelling.** Two things must agree on what a type is
called — this index and `TypeKey`. Adding a second copy of "prelude-normalize,
then token string" would have made that worse, so it moved to
`types_util::{canonical_type, canonical_spelling}` and `TypeKey::from_type` now
derives from it. Fewer definitions than before, not more.

Generated output is byte-identical and covertest passes 48 sections — reported
because it is worth knowing, not because it was a design constraint: an
architecture change whose output moves without changing semantics or performance
would have been equally fine.

592 + 520 tests. The new one covers the case the local-fn fix exists for and fails
when `add_local_function` stops feeding the index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* An absent source position stays absent in diagnostics

Review found a failure-path regression this PR introduced, and reproducing it
against the base showed it was two faults, not one:

|  | base | this PR before the fix |
|---|---|---|
| a type only a local fn writes | `error: …` ✓ | `:0:0: error: …` ← the regression |
| a captured item with no position | `:0:0: error: …` | `:0:0: error: …` ← pre-existing |

`lower_signature` lowers a `sig!(..)` against `SourceLocation::default()` —
`Origin` requires a location and a build-script signature has no file. Indexing
those types flipped their cells from `Adapter` to `Source`, and
`TypeSubject::location()` returned the default unconditionally, so the diagnostic
printed a position that reads as real. The same fault already showed for any
hand-built stream, whose captured items carry default locations too.

**Having a reading and having a reportable position are different facts.** The
classification fix stays — those types genuinely do have readings — and
`SourceLocation::has_position()` names the other one, on the type that owns the
question. `TypeSubject::location()` filters on it, which fixes the regression and
the pre-existing case through the same path.

The test pins both directions: a local-only type reports without a position while
still being reported at all, and a captured item with a real position still prints
`src/lib.rs:12:3`. It fails if the filter is removed **and** if `has_position`
starts answering `false` for everything.

Also: `Flat::type_refs()` is deleted rather than re-documented. It was added in
#239 to feed the registry's index, that index now lives inside `Flat`, and it has
zero callers — a public iterator whose docs told consumers to build exactly the
map `type_ref` now is. `index_types_of` walks `element_type_refs` directly, so
nothing depended on it.

593 + 521 tests, byte-identical generation, covertest 48 sections, MSRV clippy
clean, doc-link warnings unchanged at 16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The map had drifted from the code on almost every concrete claim, and it is the
document the umbrella PR mirrors — so the umbrella was wrong too.

**Names and shapes.** `core::language` → `core::flat`, and the design section
described structs that no longer exist: `Type { kind, syntax }` is
`TypeRef { kind, origin }`; `Param` carries an `Origin`; `Variant` is a sum with
`alternatives` while the C-style `Enum` is a separate entity with `values` — the
old single `Variant { tag, discriminant, fields }` conflated both. The
where-does-each-fact-live table pointed at fields that were renamed or split.

**L0's own checklist was wrong about L0**: it listed a `Passthrough` variant that
#227 deleted during that stage. Restated at the level that survived, with the
variant list left to L0.5 where it is accurate.

**L0.5 claimed two things that later changed**: `MaybeUninit<T>` became
`RefMode::Out`, not `TypeKind::Uninit` — an out-parameter is a property of the
borrow, not a wrapper type — and `Cow<'_, [u8]>` is no longer open, since #236
made it transparent like `Box<T>`. `Duration` remains genuinely open.

**L1.5 is new**, and recording it is the point of this commit: #239#246 were not
a planned stage, they fell out of reviewing L1, and the map should show where the
program went rather than where it was aimed. Seven registry fields deleted, the
type table carrying the frontend's reading, `Guard`, `Name`, aliases counting as
declarations, and the type index moving to its owner.

**The numbers are re-measured, and one of them is unflattering**: the ledger is
still **202**. L1.5 deleted 113 map reads but took only two classifiers off the
ledger. Saying "still 202, the ledger has not started falling" is the honest
report; L2 is where it does. Per-area counts corrected (`api/core` 71, `jnigen`
106, `unfold` 16, `registry` 11).

**L5's first bullet is already done** — L1.5 deleted the public item maps outright.

**The review protocol is rewritten.** It said byte-identical artifacts were a
gate and "a diff is a bug". That is backwards, and I had been applying it: in #243
it argued me out of a correct fix because the fix would have emitted one extra
`cargo:warning=`. `regen-check.sh` is instrumentation — it says what moved, not
whether the change was allowed. Output that moves without changing semantics or
performance is fine, and no architecture decision may be reshaped to keep bytes
matching. The protocol now also states how to run the check so it means anything:
clean `examples/` first, then `cargo clean` the two crates, because it only
regenerates what cargo rebuilds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bring the stage map up to what the program actually did
@milyin

milyin commented Jul 31, 2026

Copy link
Copy Markdown
Owner Author

Body re-synced from docs/language-integration.md at ddb2dc9 (#247), in the order this document requires: doc first, body second. Verified by round-trip — the live body's shared section is byte-identical to the file.

The map had drifted from the code on nearly every concrete claim, so this is a correction, not a status bump:

  • Renames unpropagatedcore::languagecore::flat, including the module link, which pointed at a path that no longer exists.
  • The design section described dead structsType { kind, syntax } is TypeRef { kind, origin }; the single Variant { tag, discriminant, fields } is now two entities, a sum with alternatives and a C-style Enum with values; StructFields never existed.
  • L0's checklist was wrong about L0 — it listed a Passthrough variant that Parse the record stream into elements that keep their syntax (#211) #227 deleted in that same stage.
  • Two L0.5 claims supersededMaybeUninit<T> became RefMode::Out (an out-parameter is a property of the borrow, not a wrapper type), and Cow<'_, [u8]> stopped being open in Cow<'_, T> is transparent, like Box<T> #236. Duration is still genuinely open.
  • L5's first bullet was already done, by L1.5.
  • L1.5 addedThe type table carries Flat's reading of each type #239Flat owns the type index too #246 were not a planned stage; they fell out of reviewing L1. The map should show where the program went, not only where it was aimed.

Two worth calling out rather than leaving in the diff:

The ledger is still 202. The 113 registry map reads are gone — L1.5 deleted the maps — but only two classifiers came off the ledger. The doc now states that plainly instead of implying progress that has not happened. L2 is where it starts falling.

The review protocol said the opposite of the rule. It read "byte-identical artifacts… a diff is a bug", and that rule had real consequences: in #243 it argued me out of a correct fix, because the fix would have emitted one extra cargo:warning=. It now says regen-check.sh is instrumentation — it reports what moved, not whether the change was allowed; output that moves without changing semantics or performance is fully acceptable; and no architecture decision may be reshaped to keep bytes matching. It also records how to run the check so it means anything, since it only regenerates what cargo rebuilds.

Next stage is L2api/core stops classifying source syntax, where the ledger's largest single file (types_util, 40) lives.

…atched (#248)

* Delete the pattern engine; the model already names the one shape it matched

The registry could compose converters for any parametrized type: a four-rank
wildcard table, a general unification engine, `Foo<_, _>` patterns at any depth.
The universality carried no traffic.

**The table had one entry, in the whole crate:**

```rust
// builder.rs — the only insert into either table
let pattern: syn::Type = syn::parse_quote!(Result<_, _>);
jni.output_wrappers[2].insert(key, ..);
```

`input_wrappers` was **never** inserted into, so `match_user_input` always
returned `None`; the rank-1 lookups heading `input_wrapper_shape` /
`output_wrapper_shape` were always-`None` prologues to their real hardcoded
logic; and no public API could register a pattern. The code's own comment said
it: *"The rank tables are internal — this is their only entry."*

And `Result<T, E>` is `TypeKind::Fallible` in the model. A unification engine
expressed one fact the frontend states outright.

Gone: `match_pattern`, `unify`, `immediate_pattern_children`, `wildcard_count`,
`lifetime_eq`, `token_eq`, `substitute_wildcards`, `ordered_patterns`,
`ordered_input_patterns`, `ordered_output_patterns`, `match_user_input`,
`match_user_output`, `WrapperFn`, and both rank tables. `lookup_input` /
`lookup_output` lose their `pat`/`args` parameters — with the tables gone they
answer only for `convert!`, which was always their only live path.

**The `ConverterImpl` tail is extracted, not rewritten.** Terminal-vs-composed
detection, exception binding and metadata assembly are the subtle part, so
`build_output_converter` holds them verbatim and both survivors call it: the
`convert!` path with `arg0: None`, the `Result` peel with `Some(ok)`. That
mapping is exact — the old `rank == 0` tested precisely "no peeled inner".

**Measured, not assumed:** the syntactic fallback in `fallible_parts` **never
fires** — zero occurrences across covertest-kotlin and perftest-kotlin, because
#246 indexes a binding-local fn's types, so even `sig!((..) -> Result<Summary,
String>)` has a reading. It is kept rather than made a hard error, since an
out-of-tree consumer may compose a `Result` the model never sees, and it costs
nothing: `result_parts` already existed with six other callers.

**No new test.** The plan called for one pinning the peel; the existing suite
already does, verified by sabotage — removing the peel fails **8** tests across
`snapshots`, `flatten`, `sealed` and `cross_artifact`. Adding a ninth would be
decoration.

**Ledger 202 → 167** (`types_util` 40 → 14, `jnigen/builder` 13 → 4) — a drop of
35, and the first real fall in this program: L1.5 moved 113 map reads but took
only two classifiers off.

Generation byte-identical, covertest 48 sections, 590 + 518 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop documenting the dispatch that was deleted

Review's point: the PR's purpose is architectural deletion, so leaving the old
model in adjacent documentation makes the surviving code harder to read.

**Four broken intra-doc links**, all confirmed by `cargo doc
--document-private-items`: `[`match_pattern`]` in `types_util`, `[`WrapperFn`]`
in `builder`, and `[`Self::input_wrappers`]` / `[`Self::output_wrappers`]` in
`mod`. Each is rewritten to describe what is there now rather than repointed —
the lifetime rule stands on its own reason, `lookup_input` answers for
`convert!`, and terminal dispatch is opaque → enum → `convert!` → primitive →
struct.

**Prose that still described a table with no writers**: "the user-wrapper table
(`match_user_*`, any depth, specificity-ordered)", "the rank-0 user table", "the
rank-1 user table" ×2, "the unified user-registered wrapper table", "before the
wrapper tables", and a comment explaining how to override `Result<_, _>` by
registering a more specific rank-1 pattern — an instruction for an API that no
longer exists.

**The ledger's blind-spot list** named `match_pattern` as a classifier the check
could not see. That gap is now closed rather than open, and the header says so
instead of listing it.

Surviving "rank-0" mentions are left alone deliberately: in the adapters they
read as "terminal, not composed", which is still true and is a property of the
converter rather than of the deleted table.

**On the removed public API** — `types_util::match_pattern` and `wildcard_count`
were `pub`. They are absent from the released `0.4.1`, so this only affects
someone tracking `language-integration` directly; noted here since the crate's
0.5 policy is a new surface with no back-compat shims.

Docs only. 590 tests, generation byte-identical, MSRV clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* The caller states its declarations; the registry stops asking

`DeclaredItems` was a private 13-field struct assembled by
`DeclaredItems::from_adapter`, which called **twelve getters** back into the
adapter — backed by twelve trait methods — from *inside* `resolve`.

That round-trip was ceremony. `JniGen` already holds this state; the getters
projected it out one method at a time and `from_adapter` copied it into a struct.
The struct **is** the registry's construction input. Nothing was learned by asking
for it piecemeal.

So the struct is now `pub struct Declarations` with builder methods, the twelve
trait methods are one:

    fn declarations(&self) -> Declarations;

and `from_adapter` is gone — `adapter.declarations().check()?` replaces it, with
`check` keeping the two conflict rules (a name both declared and ignored) that
`from_adapter` enforced.

**Why this matters beyond the line count.** Assembling declarations *inside*
`resolve` is what made "configuring" and "using" the same call, which is what lets
a converter be handed a half-built registry, which is why `None` from
`on_input_type` is ambiguous between *defer* and *cannot* — and that ambiguity is
the only reason the fixed-point loop exists. Stating declarations before
resolution is the prerequisite for computing a resolution order at all.

Two measurements say the rest is derivable, so S2b can compute that order:

* the five *declaration* methods that still take `&Registry` — `prerequisites`,
  `deconstructors`, `value_struct_decons`, `sum_decons`, `extra_required_types` —
  read only `flat()` and `all_source_modules()`, never a converter;
* `unfold.rs` and `expand.rs`, which compute every decomposition plan, make zero
  reads of `input_entry` / `output_entry` / `type_table`.

The twelve getters move to inherent `pub(crate)` methods on each adapter — they
are the adapter's own business now, gathered into one value at one point rather
than pulled twelve callbacks deep.

Behaviour is untouched by construction: same data, opposite direction. Generation
byte-identical, 590 + 518 tests, covertest 48 sections, MSRV clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Say what the registry is for

Its module doc was a list of fields — *"Registry holds: item maps, guards, type
tables, sidecars"* — and a stale one: the item maps were deleted in #243. Nowhere
did it state a purpose. That is why the API drifted into names like `require` and
`plans`, which cannot be read without already knowing the answer: require *what*,
plan *what*?

Replaced with what it is:

> **Which type conversions a binding needs, and whether it has them all.**

and the four things that make that concrete:

**The boundary is the wire.** A binding puts a wrapper on each side — generated
Rust the destination language can call, and destination code shaped to match. The
wrapper's *body* speaks source Rust, its *signature* speaks wire (`jlong`,
`*const T`). The translation between them is a conversion. There is a diagram,
because the three-way relationship is the thing everything else hangs off.

**A conversion is a chain, not a function** — `destination`, a wire-facing
`function`, and `pre_stages`. That is *how* composition works: `Option<Handle>`'s
chain embeds `Handle`'s. And a composite need not cross whole: `Option<T>` may be
a `T` with a niche, a `(bool, T)` pair, or leaves delivered separately — the
adapter's choice, which the registry records so both sides can be written to match.

**Conversions are directional.** Two tables, not one. `&str` inbound decodes a
`jstring`, outbound allocates one, and one direction may be convertible while the
other is not. A callback flips it — `impl Fn(Sample)` is an input whose argument
crosses outbound.

**It derives the set, then checks completeness — and never writes a conversion.**
A binding names a surface; far more types must convert than were named, and
computing that closure is the work. Completeness is a meaningful check precisely
because the set is derived here rather than handed over. But only the adapter
knows what a `jlong` is, so the registry asks for each and fails naming what could
not be supplied.

Plus an in/out table: model, crossings, decompositions, conversion builder → a
conversion per type in the closure, or a failure naming the gaps.

Docs only. 590 tests, generation byte-identical, MSRV clippy clean, doc-link
warnings unchanged at 17 (measured against this branch's parent, not a different
one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Say how a registry is used, not just what it is for

#250 stated the purpose. This states the shape that follows from it:
configure it, hand over the answers, read it — and nothing in core calls
back into the generator.

A `next_request`/`supply` pull loop is not an alternative to a callback
trait; it is the same protocol with the arrow flipped. What removes the
protocol entirely is the sort: `immediate_edges` is structural, so the
demand can be handed over inner-first as a plain list, and a generator
building `Option<Handle>` already holds `Handle`. Each crossing is then
offered exactly once, which is also what makes a generator's `None` mean
`cannot` rather than `not yet`.

Records the two consequences worth knowing up front: a `None` is not a
failure (reachability from the exports decides), and a self-referential
type has no order, so `crossings` breaks the cycle at its entry.

Docs only. 590 tests, regen-check byte-identical, MSRV clippy clean,
doc warnings unchanged at 49 measured against this branch's parent.

* Move the skip report out of the registry

First code step of #251. The registry answers "which conversions does this
binding need, and does it have them all"; which items a binding skipped
bears on neither. Four inputs were read only to print `cargo:warning=`
lines — `ignored_functions`, `ignored_types`, `ignored_consts` and
`ignored_name_predicates` — so they leave with the five println! loops,
into `core::diagnostics`, and a generator calls `warn_unclaimed` itself.

Two things fall out. `Declarations::check()` and its two ScanError
variants existed only to reject "declared AND ignored", which is now
unrepresentable. And the report is built as lines and then printed, so it
is asserted on directly instead of scraped off stdout — seven tests that
could not exist before, one of which caught a dropped ignore-suppression
while writing it.

`consts: Option<HashSet>` STAYS: the plan called it a warning switch, but
`write.rs` uses `None` to mean "re-emit every const verbatim", which is
what cbindgen relies on. Its doc now says which half needs the sentinel.

Interim: generators call this from `validate`, the earliest hook they own
that sees the model, running exactly where the registry printed before.
It moves to `generate` in phase E.

593 tests (590 - 4 + 7), warning output byte-identical on covertest-kotlin
and example-cbindgen, regen-check clean, MSRV clippy clean, doc warnings
unchanged at 49. -250 lines.

* One way to build a registry: Registry::new(flat)

`RegistryBuilder` was a verbatim duplicate of `FlatBuilder` — the same
source/source_named/items/build, differing only in appending `from_items`
— and the registry's own doc admitted it ("the same shape Flat reads
prebindgen data with"). Reading captured output is Flat's job, so a build
script now says where items come from once, at the layer that owns the
question:

    let flat = Flat::builder().source(FLAT_OUT_DIR).build()?;
    Registry::new(flat)?.resolve(adapter)?.write_rust(out)?;

`Registry::{empty, from_items, builder}` and `RegistryBuilder` are gone;
`from_flat` becomes `new`, no longer disambiguating against a sibling.
The NotExpressible check stays here: an item the flat language cannot
express is a hard error whatever a binding declares.

One extra named type per build.rs against 54 lines of duplicate builder
and two redundant constructors. Test fixtures get `test_util::reg_from_items`
rather than repeating the two steps in ~40 places.

593 tests, warning output byte-identical, regen-check clean (the two
untracked example_flat_aarch64_unstable.* files reproduce identically on
the parent after the same clean — that is #219, not this), MSRV clippy
clean, doc warnings unchanged at 49.

* Correct the input: elements alone cannot name every crossing

The shape landed in 2428db4 said the configure step is `export` +
`decompose`, on the reasoning that types are reachable by walking a
declared element's signature. Measured, and it is half true.

Dropping the declaration-as-root for declared types leaves regen-check
byte-identical — so for every type with a captured body, deriving per
usage really is enough, and it is the more correct rule (an output-only
type stops being demanded as an input).

It fails for a type with NO captured item behind it:
`ptr_class!(zenoh::KeyExpr<'static>)` on a re-exported foreign type
appears in no signature this model can walk. Nothing derives it, so the
declaration is the only statement that it crosses at all — two tests
pinned exactly that and caught the claim.

So the input needs `cross(type)` beside `export(name)`: the narrow escape
hatch for the no-element case, not the common path. Still four inputs
against Declarations' twelve setters.

Docs only. 593 tests, doc warnings unchanged at 49.

* Push declarations in; the registry stops asking

Step 1 of three. `resolve` used to CALL the generator to find out what to
build — `declarations()`, `local_functions()`, `extra_required_types()`,
three of the twenty Prebindgen hooks. That is the callback the module doc
forbids, so it is inverted: the generator states its binding, and the
registry records.

    jni.declare_into(&mut registry)?;   // generator pushes
    jni.resolve(registry)?              // pairs the two; registry never asks

`Registry` gains export / export_const / export_type / cross / reference /
local_function, and `Declarations` plus its twelve setters are deleted. The
generator drives `resolve` because it is what knows both halves — which is
also the shape `generate(..)` takes when emission moves there (phase E).

`cross` is directional. Three of the old inputs were one-sided
(`required_output_types` output-only, `extra_required_types` per-direction)
and one was implicitly both; stating direction at the point of declaration
is what stops an output-only crossing from silently lacking its input twin.

accessor / method_receiver / crosses_only_in_pieces ride along with a
comment: they are properties of a decomposition, and move onto it in step 2.

593 tests, warning output byte-identical, regen-check byte-identical, MSRV
clippy clean, doc warnings unchanged at 49.

* Five decomposition callbacks become one stated value

Step 2 of three. `expansions`, `deconstructors`, `value_struct_decons`,
`sum_decons` and `leaf_vec_fold_elements` were five separate calls the
registry made back into the generator from inside `resolve`. All five are
implemented by one adapter, none by the other, and — measured while
planning this — not one of them ever reads more than `registry.flat()`.
So they are stated up front instead of asked for:

    registry.decompose(Decompositions { .. });

`boundary_only_types` moves onto it as `replaces`, where the fact comes
from: a type crosses only in pieces BECAUSE something decomposes it, so
listing it separately was two statements of one thing.

The five fields are still the five declaration families. Unifying the plan
IRs behind them is #223, and collapsing them here would only move that
seam while pretending it was closed — what this settles is when they are
stated and by whom.

Prebindgen is down from 20 hooks to 12; the 8 gone are every "what should
I build" question. The 12 left are emission (phase E) plus the three
conversion hooks step 3 replaces.

593 tests, warning output byte-identical, regen-check byte-identical, MSRV
clippy clean, doc warnings 49 -> 36 (deleted hooks took their links).

* Hand over the demand; delete the fixed-point loop

Step 3 of three, and the one the other two were clearing the way for.
`on_input_type` / `on_output_type` / `dispatch_fn_input` were the last
questions core asked the generator, and the fixed-point loop existed only
because the order those were asked in was arbitrary. Both are gone:

    let order = registry.crossings();      // sorted, inner types first
    for c in &order { ... }                // the generator's own loop
    registry.supply(built)?;               // graded once

`crossings` sorts by `immediate_edges`, which is structural, so no
generator is consulted to derive it. Each crossing is then offered exactly
once, and `None` means CANNOT rather than NOT YET — the ambiguity #249
named as the cause of converters reading a half-built registry.

Two dependencies the structure cannot show, both found by tests rather
than by reasoning:

  * a callback argument delivered as plan leaves needs those leaves'
    conversions, and a leaf is named by a plan, not by the argument's
    syntax. Derived in `plan_edges`.
  * a `convert!` chains through a helper's parameter type, which nothing
    about the target type mentions. The generator states it: `depends`.

The old loop papered over both by retrying. Making the order explicit is
what turns them from invisible into stated.

`Conversions` is the seam: `Building` is the partial view a generator
builds against, `Registry` the total one everything else reads, and a
helper serving both takes `&impl Conversions<M>`. 39 signatures moved.

Cycles have no topological order, so `crossings` breaks one at its entry
— the single case where "every inner first" is not literally true. No
example has a recursive type, so this adds a test instead of trusting
byte-identity.

Prebindgen: 20 hooks -> 9, all emission. 594 tests, warning output
byte-identical, regen-check byte-identical, MSRV clippy clean.

* Take expansion_plans back off the Conversions trait

Self-review of ec0e1df. The plan accessors went on the trait to stop the
generic substitution spreading into emission code, and I flagged the result
as looser than conversion-building needs. Measuring which callers are
actually generic:

  unfold_plans / error_plans / decon_plans / callback_arg_plan(s)
      6 generic callers — the callback + iface_spec path, reachable while
      a conversion is being built. These have to be on the trait.

  expansion_plans
      0 generic callers. All five sites (fn_plan, render, report,
      overloads, wrapper) hold a concrete &Registry and always will:
      parameter folds are read at emission, never while converting.

So it comes off, and those five read the field directly again. One less
thing `Building` shows a generator than it has any use for.

594 tests, warning output byte-identical, regen-check byte-identical, MSRV
clippy clean.

* Close the registry's fields; split it into a module

Two changes, both about what the registry shows.

**Fields are crate-internal.** `input_types` / `output_types` and the five
plan maps were `pub`. Outside the crate a table is now reached through
`Conversions::conversion` and `crossings` — which is what makes direction a
parameter rather than half of a field name, and what stops anyone observing
a cell before `supply` has graded it. `expansion_plans` gets an inherent
accessor (it is emission-only, so it stays off the `Conversions` trait, per
9fdbb9e); the other maps already had one. `pub(crate)` rather than private
because `expand` and `unfold` fill them — they are core's own state, just
not the world's.

**One 1989-line file becomes eleven, none over 420.** Grouped by what they
answer, not by type:

    mod         Registry, Declared, Decompositions, wiring
    key/cell    TypeKey; TypeSubject/TypeCell/TypeEntry/Direction
    declare     configure — every method records, none derives
    model       questions about the model
    scan        derive the crossing set
    order       hand the demand over, grade the answers
    run         prepare/finish/apply plans
    view        Conversions + Building
    error       what can go wrong
    walk        structural type-graph helpers

Inherent impls span modules, so `impl<M> Registry<M>` splits with them.

Two things the split surfaced rather than caused:

  * `TypeKeyParseError` and `DuplicateNameError` are reachable from the
    public API — `TypeKey::parse` returns one, `ScanError::DuplicateName`
    carries the other — and were never re-exported. Now they are, along
    with `NotExpressibleEntry`.
  * the boundary ledger moves 11 classification sites from `registry.rs` to
    `scan.rs` (2) + `walk.rs` (9). Total unchanged at 167: relocation, no
    new classifier.

594 tests, warning output byte-identical, regen-check clean, MSRV clippy
clean, doc warnings 44 -> 43.

* Retire the docs that describe deleted machinery

Closing the fields turned a stale doc into a broken link, which is how I
noticed the prose had not kept up with three commits of deletion. Swept
core and lib.rs for every reference to something that no longer exists:

  * `Registry::input_types` / `output_types` — now crate-internal, so the
    module doc pointed readers at fields they cannot reach. It explains
    `Direction` and `Crossing` instead, which is the answer to the question
    that paragraph was actually asking.
  * `on_input_type` / `on_output_type` / `dispatch_fn_input` — the
    `Prebindgen` module doc still opened by describing them as the trait's
    main job. The trait has one job left: per-item emission. It says so, and
    says where conversion went.
  * `core`'s "phase-oriented pipeline" list and lib.rs's "# Flow" both still
    walked through `Registry::resolve` and the fixed-point resolver. Both
    now describe crossings/supply, and lib.rs no longer advertises
    `on_input_type_rank_0..3`, which has not existed for far longer than
    this branch.
  * one comment in `resolve.rs` explaining an ordering constraint in terms
    of the loop that enforced it.

Only deliberate mentions survive — the `Prebindgen` doc naming what is gone
so a reader coming from an older version knows where it went.

Docs only. 594 tests, MSRV clippy clean, doc warnings 43 -> 42.

* Retire TypeCell and TypeSubject from the public API

Auditing what a third-party generator can actually reach — the point of
this whole branch — turned up five exports no generator uses. Three are
right: `TypeKeyParseError`, `DuplicateNameError` and `NotExpressibleEntry`
are unreachable by accident but nameable on purpose, since `TypeKey::parse`
returns one and `ScanError` carries the others.

`TypeCell` and `TypeSubject` are not. Closing the type tables in 6af68bb
left nothing public that returns or accepts either — they became API a
caller could name and never obtain. `pub(crate)`.

Which then showed what was only alive because it was public:

  * `TypeSubject::syntax` — read by nothing at all.
  * `TypeSubject::kind` — read only by tests, pinning that a source
    reading survives into a cell. Kept, `#[cfg(test)]`, so the lib build
    stops pretending it has a caller.
  * `TypeSubject::Adapter(syn::Type)` — the payload was never read back,
    only matched as `Adapter(_)`. Now a unit variant, and `test_util::cell`
    loses the key argument it only had to build one.

None of this was reachable before the fields closed, which is why it sat
here: dead code inside a public type looks alive.

594 tests, warning output byte-identical, regen-check clean (no tracked
drift), MSRV clippy clean, doc warnings 42 -> 41.

* Box TypeSubject::Source — stable clippy, not MSRV

Making `Adapter` a unit variant in 7d0f985 left `Source(TypeRef)` as the
only variant carrying anything, and `large_enum_variant` compares the
largest against the SECOND largest: a 264-byte enum whose runner-up is
empty. Boxing takes it to 8, which is the right shape anyway — cells are
numerous and most are `Adapter`.

The miss is in how I checked, not what I changed: CI's clippy runs on a
`[1.85.0, stable]` matrix, and this lint fires only on stable's 1.97
clippy. I verified MSRV and stopped, so a green local run said nothing
about the job that failed.

* Split building a registry from reading one

`Registry` was both: `&mut self` declaring methods and read-only accessors
on one type, so "still being described" and "finished, and answerable" were
a phase you had to be careful about rather than something the types knew.
Now `RegistryBuilder` owns everything mutating and `build()` is the only
way to get a `Registry`. Nothing can add a crossing to one, which makes
"every crossing has a conversion" a fact about the type.

    let registry = Registry::builder(flat)?
        .export(&name)
        .decompose(decompositions)
        .convert_with(|crossing, built| gen.convert_crossing(crossing, built))?
        .build()?;

Declarations consume `self`, so they chain. Two ways to hand conversions
over, per your request:

  * `convert_with(f)` — chainable; `f` is called per crossing in dependency
    order with everything already built. This is a callback, and it is not
    the thing we removed: the registry does not re-enter generator logic on
    its own schedule, the walk is finished before the method returns, and
    the closure is the caller's. It is `crossings` + a `for` loop, written
    once instead of in every generator.
  * `crossings()` + `conversions(map)` — for filling the holes yourself.
    `conversions` accumulates, so the two compose.

`prepare`/`supply` are gone; `validate` now takes the `Building` view
instead of a whole `Registry`, which is all it ever read.

`scanned()` is `#[cfg(test)]`: it is the state between described and
answerable, which is exactly what this split exists to keep out of everyone
else's hands.

594 tests, warning output byte-identical, regen-check clean (no tracked
drift), BOTH clippy toolchains clean, doc warnings 41 -> 34.

* Delete Registry::scan_declared — the split was still leaking

Self-review of 21c403c: I claimed a strict builder/read-only split, then
checked. `Registry` still had one public `&mut self` method, so the claim
was not yet true — a caller could scan a finished registry.

Zero callers: `RegistryBuilder::derive` subsumed it the moment the builder
landed. Its doc was stale too, still describing `adapter.ignored_functions()`
and the skip warnings that left for `core::diagnostics` several commits ago.

Now `Registry` has NO public mutating method, and the only `&mut self` left
is `RegistryBuilder::crossings`, which caches the derivation.

594 tests, warning output byte-identical, regen-check clean, both clippy
toolchains clean, doc warnings unchanged at 34.

* Update the docs the builder split invalidated

Checked the module doc against the API it describes and found its worked
example wrong in four ways: `export` does not return `Result`, `cross` takes
a direction, `supply` no longer exists, and the whole thing still used
`Registry::new`. A worked example that does not compile is worse than none —
it is the first thing a generator author copies.

Rewrote it around the two types, since that IS the change: a builder is
still being described, a registry is finished and answerable. Added the
`crossings`/`conversions` alternative, and said plainly why `convert_with`
is not the callback we removed — the walk finishes before it returns, the
closure is the caller's, and the builder chooses nothing about when it runs.

Swept the rest: `Registry::new` in lib.rs's doctest and four module docs,
`Registry::prepare` in the core pipeline description, and two references to
`Registry::scan_declared` — deleted in b708c7e — in write.rs and
kotlin_emit.rs.

Docs only. 594 tests, warning output byte-identical, regen-check clean, both
clippy toolchains clean, doc warnings 34 -> 33.

* Rename the generators to what they are: builders

Mechanical, and alone in its commit so the next one is reviewable. Today's
`JniGen` and `Cbindgen` are pure declaration holders — everything on them
either records what to emit or answers a question about it — so they are
`JniGenBuilder` and `CbindgenBuilder`. That frees the short names for the
built objects the next commit introduces, matching the convention already
here: `Flat::builder()`/`FlatBuilder`, `Registry::builder()`/`RegistryBuilder`.

Renamed OUTSIDE string literals only. Three of those strings matter:
`"// Auto-generated by JniGen — do not edit by hand."` is written into every
generated file, and two `"JniGen::on_function …"` diagnostics are user-facing.
Renaming inside them would have moved the goldens and made this commit
unreviewable — the header appears in committed output.

Prose and doc links follow the code for now; the next commit revisits them,
since it is the one that makes `JniGen` mean something again.

594 tests pass untouched, regen-check byte-identical (no tracked drift),
warning output byte-identical, both clippy toolchains clean, doc warnings
unchanged at 33.

* The generator owns the model and the registry

A build script had to know three types and a four-step dance to say
"generate bindings from this directory":

    let flat = Flat::builder().source(DIR).build()?;
    let registry = Registry::builder(flat)?;
    let gen = jni.resolve(registry)?;
    gen.write_rust(&rs)?;

`Flat` and `Registry` are pipeline internals. Now:

    let jni = JniGen::builder()
        .package(..).fun(..)
        .source(DIR)
        .build()?;
    jni.write_rust(&rs)?;
    jni.write_kotlin(&kt)?;

`JniGenBuilder`/`CbindgenBuilder` gain `source` / `source_named` / `items` —
the same three feeders `FlatBuilder` has, because they ARE that feeder: the
builder holds a `FlatBuilder` and `build()` runs the pipeline the caller
used to run by hand. `JniGen` and `Cbindgen` are the built objects, each
holding its registry as a field, each publishing its own writers.

`Generation<E>` is deleted, and that is the point rather than a side effect:
core no longer owns the artifact-bearing type, so a generator decides what
its artifacts are and what they are called. `core::write::write_rust` stays
a free function both call. `Registry::finish` goes with it — the post-resolve
invariant check is now the generator running its own `validate_resolved`,
which is the one place that knows what an invariant means here.

`build_with(registry)` is the crate-internal seam tests use to feed
synthetic items without a directory; `build()` is that over `source`.

594 tests, warning output byte-identical, regen-check byte-identical with no
tracked drift, both clippy toolchains clean, doc warnings unchanged at 33.

* Name the example variables after what they now hold

`let gen = jni.build()` read backwards once `JniGen` became the built type:
the thing called `jni` was the builder, and the thing called `gen` was the
JniGen. Now `binding` builds and `jni` is what you write from.

The examples are how this API is read before it is used, so the names being
the wrong way round is worth a commit of its own.

Output byte-identical, warnings byte-identical, regen-check clean.

* Delete Registry::supply — the read-only claim was false

Review catch (#249 review of the combined head). `21c403c` said `supply` was
gone and `b708c7e` said no public `&mut self` remained on `Registry`. Both
were wrong, and `supply` shipped: a caller could build a complete registry
and then replace any conversion in it, with only core's completeness rerun
and the generator's `validate_resolved` skipped entirely. Exactly the
half-filled mutable protocol this stack claims to have removed.

It had no callers — handoff is `RegistryBuilder::{convert_with, conversions,
build}` — so it is deleted outright.

**Why it survived two commits that checked for it.** I verified with
`grep "pub fn .*&mut self"`, which needs both on one line; `supply`'s
signature spans four. The check could not see the thing it was for.

So the replacement is a test that strips ALL whitespace before matching,
making a multi-line signature indistinguishable from a one-line one. I
confirmed it fails by reintroducing a `pub fn __regression_probe(&mut self)`
— the first version of the test passed with that present (the whitespace
collapse left a space after `(`), which is the only reason I found out it
was useless.

`Registry::crossings` goes `pub(crate)` with it: same residue, no external
caller, and the read phase in the module docs never listed it.

Swept the docs the review enumerated — `lib.rs`, `core/mod.rs`, `resolve.rs`,
`order.rs`, `registry/mod.rs`, `write.rs`, `kotlin_emit.rs`, `declare.rs` —
all still describing `Registry::supply`, `Registry::finish`, or linking
declaration methods that now live on `RegistryBuilder`.

595 tests (594 + the guard), warning output byte-identical, regen-check clean
with no tracked drift, both clippy toolchains clean, doc warnings 33 -> 32.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
milyin added 2 commits August 1, 2026 00:54
#254)

Two things the map got wrong since #247 synced it.

The ledger is 167, not 202. #248 deleted the pattern engine and took 35
sites with it — types_util 40 to 14, jnigen/builder 13 to 4 — so L2 is in
progress, not "not started", and the claim that the ledger has not begun
falling is false. Recorded with the distinction that matters: those 35 went
away because their code went away, which is deletion rather than migration,
and the 45 that remain in api/core are the ones that have to start reading
elements.

The #249-#253 stack is merged but not on this branch. It landed PR-into-PR
onto flat-drop-pattern-engine, of which only #248's commit ever reached
language-integration, leaving 28 commits — the registry and generator API
redesign tracked by #251 — invisible to the map. Recorded as L1.75 for the
same reason L1.5 is recorded: the map should show where the program went.
…#255)

#254 recorded the #249-#253 stack as merged elsewhere and pending a
re-merge. It is not pending: #248 squash-merged flat-drop-pattern-engine
AFTER #249 landed the stack into it, so d845c8f carries the registry and
generator redesign under a title naming only the pattern engine.

flat-drop-pattern-engine still reports 28 commits ahead because a squash
records no ancestry. The trees differ by nothing, which is the check that
should have been run: declare.rs, run.rs, view.rs and order.rs are present,
convert_with is present, Registry::supply is gone, and both adapters expose
builder(). Says so in the section, since the same misreading is available to
anyone who opens the log.
registry/walk.rs is deleted. immediate_edges takes its structural children
from TypeKind instead of taking a syn::Type apart, and spells each edge from
the child's own origin.syntax -- classify off kind, spell off syntax.

Three of the deleted arms were dead rather than migrated: lower_type refuses
non-unit tuples and raw pointers, and Group/Paren are transparent in the
model, so nothing the frontend accepts could reach them. The Type::Path
generic-args arm is dead for a reason already written down in named() --
generic arguments are lowered but not retained, because no declaration takes
type parameters.

A composed type is ADMITTED to the model, not classified on the fly.

The walk needs a reading for every type it is handed, and expansion composes
spellings the source never wrote -- an Option<T> around a T it found. My
first attempt gave Flat a query that lowered on an index miss and answered
without recording anything. That was wrong, and the tree already says so:
add_local_function lowers a binding-local sig!(..) through the same grammar
and ADMITS it, because otherwise the "one index" #243 established is a lie
the moment a binding composes something.

So Flat::admit_type is that function's peer, and ensure_entry -- the one
place a cell is born, and therefore the one place a type enters the pipeline
-- is where it is called. immediate_edges goes back to a plain index read,
because by the time the walk reaches a type the cell for it already exists.
Every later lookup gets the same answer from the same place.

Measured after the change: ZERO types are refused by the grammar, across
every in-tree example and all 523 tests. TypeSubject::Adapter is therefore
unreachable, which is L2e's precondition -- left in place, with the evidence,
for the PR that deletes it.

Two more changes the walk forced, both improvements:

The field lookup takes the type's NAME from TypeKind::Named rather than from
bare_path_ident on the spelling. That is what makes a transparent wrapper
work: Box<Node> classifies as Named { Node }, so it reaches Node's fields,
where asking the syntax for a bare ident answered None and dead-ended.

A declared type the source never mentions is now classified-but-placeless
rather than unreadable. Foreign is a name, and the grammar can say that much
about any spelling that parses; what is genuinely absent is a file and line.
That is the reading-vs-position distinction L1.5 drew, applied to the case
that shows why it matters. No production behaviour moves -- location() was
already None for it, and kind() is test-only.

registry/scan.rs keeps its two sites, with the reason in the code: they
inspect a key a BUILD SCRIPT AUTHOR wrote, to diagnose that spelling. No
source type is being classified, so there is no element to read instead.
That is the map's "legitimately the adapter's business" case, and the first
entry to actually land in it -- so L2a is 9 sites, not the 11 planned.

Ledger 167 -> 158.

Reported: regen-check drifts ONE file -- perftest-kotlin loses 50 lines,
nothing added. They are JString_to_String_c7f3ca43 and its output twin, and
they were provably dead: the committed file mentions that hash exactly twice,
both definitions, zero call sites. Box<String> IS String in the model, so the
old syntactic walk registered a plain-String cell that nothing ever used.
Explained: dead generated code stops being generated; no live converter,
signature or Kotlin file moved. Asserted: every structural edge in the scan
now comes from a classification, and every type in the table has one.

cbindgen::type_contains_vec goes with it -- its one call site already held
the TypeRef and was digging the syntax back out. TypeKind::Sequence is the
whole question, since Cow<'_, [T]> lowers to it just as Vec<T> does, so the
two spellings it tested separately are one classification. is_vec and
cow_slice_elem stay; they have other callers.
milyin added 2 commits August 2, 2026 14:23
* flat: seal TypeRef — only the model may mint one

`TypeRef` was `pub struct { pub kind, pub origin }`, re-exported at
`prebindgen::core::flat`, with four public composers from #278. Anyone could
assemble one, and nothing checked that `kind` agreed with `origin.syntax`, so
holding a `TypeRef` proved nothing about where it came from.

The invariant it now carries:

  Every TypeRef was classified by the model. Flat classified it from source
  syntax, or the registry composed it by layering over something already
  classified. Nothing above the model can mint one.

Fields become `pub(super)`, composers `pub(crate)`, and reads go through
`kind()` / `syntax()` / `location()`. There is no cheaper version: a public
field IS a constructor, so restricting only the composers would block nothing.

The invariant is unconditional — no phase, no lifetime, no direction — which is
what makes it hold for a STORED value. That was the requirement: a `TypeRef`
lives in `UnfoldLeaf::out_ty` and `FoldLeaf::ty`, inside plans the registry
itself stores, so any borrow-carrying token would make the registry
self-referential.

It deliberately does NOT claim the converters exist. That is false by design for
stored readings: `unrequire_output` exists precisely to leave a cell whose
converter cannot resolve (a `Vec<opaque-handle>` delivered element-by-element —
"a jlong wire is not JObject-shaped"), and a `SumTag` leaf never has one. So
converter existence stays a lookup answering `Option`, and the relation is 0..2,
not 1-to-1.

Two compile-fail doctests are the acceptance test, each verified to fail on
privacy specifically: E0451 for the struct literal, E0624 for the composer.

Mechanical otherwise: 172 read sites migrated by walking rustc's own E0616
spans rather than by pattern-matching text, so no site was missed and none was
guessed. Two `&`-artifacts of that rename would have compiled while cloning a
reference instead of a value (`suspicious_double_ref_op`); clippy caught both.

Also fixes 6 doc links this would have broken, and 2 that were already broken.

Verified: 550 lib tests, `--all --all-features` 14/14 suites, clippy
`--deny warnings` clean, fmt (CLI config), regen-check byte-identical after
`cargo clean -p`, covertest-kotlin 48/48, rustdoc no new errors (37 -> 35).

* jnigen/core: drop the remaining double borrows the rename left

Review on #280 found 12 sites where `&x.y.syntax()` / `&x.y.kind()` produces a
`&&syn::Type` / `&&TypeKind` that only compiles through deref coercion and match
ergonomics.

My own cleanup missed them: the sweep matched a SINGLE identifier before the
accessor, so `&reading.syntax()` was fixed while `&field.ty.syntax()` and
`&c.subject.kind()` were not. Same blind-spot shape as #271's census — a pattern
written for one spelling of the thing it was looking for.

Measured, because I had cited clippy as the guard here: clippy does NOT catch
this class. Reintroducing one double borrow leaves
`clippy --all-targets --all-features -- --deny warnings` at exit 0 with zero
warnings. The "clippy clean" line in #280 was true and was not evidence for
this.

The two `&element.syntax()` in `flat/tests/roundtrip.rs` are deliberately kept:
`Element::syntax()` returns an OWNED `syn::Item`, so the borrow is real. That is
why this was not a blind regex sweep.

Verified: 550 lib tests, `--all --all-features` 14/14 suites, clippy
`--deny warnings` clean, fmt (CLI config), regen-check byte-identical after
`git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48.

* flat: enforce the invariant at api::core, not just at the crate edge

P1 review on #280 is correct: the doc claimed "nothing above the model can mint
one" while every composer and `Flat::classify` were `pub(crate)`, so any
in-crate adapter could still mint — and the tree already did, at
`jnigen/emit/sum_out.rs:71`. The compile-fail tests proved only the
out-of-crate boundary. The claim was false at the commit that made it.

Enforced rather than softened. Four visibilities now draw the boundary at
`api::core`:

  borrowed / optional / scalar   pub(in crate::api::core)
  named                          pub(super)   -- flat alone
  Flat::classify                 pub(in crate::api::core)
  the kind / origin fields       pub(super)   -- unchanged

The one in-crate mint is gone rather than documented: the `SumTag` selector
needs a type the model already declares, so the DECLARATION now answers —
`flat::Variant::type_ref()` — instead of an emitter composing a reading from an
ident. That is also the better model: a consumer holding the element no longer
has to mint a reading and hope it matches what the model would have said.

Measured, not asserted: an `api::lang` adapter naming all four routes now fails
with four `E0624`s. The doctests are relabelled to say what they actually prove
(the crate edge, E0451 + E0624) and to point at the visibility table for the
stronger claim, which no doctest can reach inside the crate to test.

This does NOT resolve #281 — composition still lives in `expand`/`unfold`
rather than behind a registry API, and a composed reading is still discarded and
re-derived by `ensure_entry`. It removes the adapter-side hole only.

Verified: 550 lib tests, 21 doctests, `--all --all-features` 14/14 suites,
clippy `--deny warnings` clean, fmt (CLI config), regen-check byte-identical
after `git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48,
rustdoc 37 -> 35 errors (no new).

* flat: Variant carries its own reading instead of composing one

Second P1 on #280 is correct, and it is a hole my own fix for the FIRST P1
opened. `Variant::type_ref()` composed `TypeRef::named(&self.name)`, and
`Variant` has public fields with a public `Origin::new`, so a consumer could
assemble a `Variant` named `String` and get `Named` over the spelling `String`
— which the model reads as `Str`. Exactly the kind/syntax disagreement this PR
seals, reachable from OUTSIDE the crate, and invisible to both compile-fail
doctests because assembling the element is not minting the type.

Reproduced before fixing, out-of-crate against the built rlib:

    kind   = Named { id: TypeId { name: "String" } }
    syntax = String

The parser now takes the reading and `Variant` stores it; `type_ref()` returns
it. STORING is what closes it: whatever a caller does with the other fields, the
reading is the one the model made, and no caller can mint a different one to put
in its place. The field is `pub(super)` as a second line — a `Variant` cannot be
assembled outside `flat` at all, so `name` and `reading` cannot be paired
inconsistently with each other either.

Both halves verified: the out-of-crate forge now fails to compile, and an
`api::lang` attempt fails `E0451`.

The new compile-fail doctest is documented for exactly what it pins — "a
consumer cannot assemble a `Variant`" — and no more. Measured: it still passes
with the field made `pub`, as `E0063` rather than `E0451`, because a consumer
cannot produce a `TypeRef` to supply either way. The visibility is the check
that discriminates, and the compiler runs it every build.

Verified: 550 lib tests, 22 doctests, `--all --all-features` 14/14 suites,
clippy `--deny warnings` clean, fmt (CLI config), regen-check byte-identical
after `git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48,
rustdoc 37 -> 35 errors (no new).
)

* core: the registration path carries readings instead of re-deriving them

Closes #281. A composed reading was built, thrown away, and independently
re-derived: `expand.rs` composes `pty.optional()`, `unfold.rs` handed only its
SPELLING to `require_output`, and `ensure_entry` classified those tokens from
scratch and stored its own twin. Two classifications of one type, by two paths
that never met, and nothing compared them.

The issue proposed moving the composers behind a registry API. That would not
have closed it, and the PR says so on the issue rather than silently skipping
it: the loss is at the door, not at the composer, and it happened again at every
recursion step — `immediate_edges` had each child as a `&TypeRef` and did
`child.syntax().clone()` so the next level could re-classify it.

One rule now: a type enters the registry as a READING; only a spelling nobody
has classified yet goes through `classify`.

  ensure_entry(dir, &TypeRef, root)   stores the caller's reading, INFALLIBLE
  register_type_{recursive,inner}     take &TypeRef, infallible
  require_*/unrequire_*               take &TypeRef
  immediate_edges                     returns (Direction, TypeRef)
  intern / intern_recursive           the one fallible door, for a spelling

Infallibility falls out rather than being claimed: `ensure_entry` was fallible
for exactly one reason — `classify` refusing a spelling — and a reading has
already been through that. #281 planned to assert layering is total and pin it
with a test; carrying the reading makes the question not arise.

Ten of the twelve `require_*` sites already held a `TypeRef` and called
`.syntax()` on it at the door, so those are deletions. `unfold.rs`'s composed
`cv_ty` now uses `optional()` instead of `parse_quote!(Option<#leaf_ty>)`,
pairing kind with spelling in one place.

`Flat::classify` is down to ONE production caller, `intern`.

Acceptance test, verified to fail with the fix reverted:
`a_composed_reading_reaches_the_cell_unchanged` composes `Option<Thing>` — a
spelling the source never writes — and asserts the cell keeps the source
location. Reverted, the cell holds a PLACELESS reading, which is what a
diagnostic about that crossing would have printed.

Two mistakes caught in progress rather than shipped: `intern` does not recurse,
so six sites that were `register_type_recursive` needed `intern_recursive`
(caught by cbindgen's example panicking, not by a test); and the regex that
dropped `.syntax()` added `&` to values that were already references
(`needless_borrow`).

regen-check byte-identical — which here is EVIDENCE, not a regression check: the
cell used to hold `classify(spelling)` and now holds the caller's reading, so
identical output is the first confirmation that the two answers agree for every
type the examples exercise.

Verified: 551 lib tests, `--all --all-features` 14/14 suites, clippy
`--deny warnings` clean, fmt (CLI config), regen-check byte-identical after
`git clean -fd examples/` + `cargo clean -p` (forced rebuild confirmed by the
Compiling/Generated lines), covertest-kotlin 48/48, boundary ledger unchanged
at 127.

* core: narrow `intern` to `api::core`, matching what #280 sealed

Review on #283 is correct. I widened `intern` to `pub(crate)` so two test
modules could reach it, and never checked what else that admitted: classifying a
spelling MINTS a reading, so a `pub(crate)` door let `api::lang` hand the
registry tokens of its own and receive a `TypeRef` back — exactly the capability
#280 closed by making the composers and `Flat::classify`
`pub(in crate::api::core)`.

A one-door design is only worth having if the door is no wider than the
entrances it replaces.

Both test callers are under `api::core` (`resolve/tests.rs`,
`unfold/tests.rs`), and every production caller is in `core::registry`, so the
narrowing costs nothing.

Measured rather than assumed: an `api::lang` call to `intern` now fails
`E0624`. The doc records why the visibility is what it is, so a future widening
has to argue against the reason rather than rediscover it.

Verified: 551 lib tests, `--all --all-features` 14/14 suites, clippy
`--deny warnings` clean, fmt (CLI config), regen-check byte-identical after
`git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48.

* core: intern reuses a known reading; unrequire and Layered stop downgrading

Two review points on #283, both correct.

[P2] `intern` classified unconditionally and only then called `ensure_entry`,
whose existing-cell arm discards the reading. So a repeated registration of the
same key still derived a second reading that never met the first — the very
shape this PR removes, surviving as redundant work rather than as a replaced
cell, and contradicting the stated rule that only a spelling nobody has
classified yet goes through `classify`.

`intern` now looks the key up first, in EITHER direction (a reading is
direction-free), clones the authoritative answer, marks or creates the
directional cell, and calls `Flat::classify` only on a genuine miss. That also
restores the old `ensure_entry` property of classifying only when a cell is new.

[Copilot] `unrequire_*` still took `&syn::Type` while the PR description claimed
the whole registration surface was reading-based. The description was the thing
that was wrong, so the code is now what it claimed:

  unrequire_output(&TypeRef)   pairs with require_output
  clear_root(dir, &TypeKey)    the keyed primitive underneath

Keyed is the honest signature for `clear_root`: un-requiring creates no cell and
classifies nothing, so it is the one registration-adjacent operation with no
reading to carry. `run.rs` already held keys and now passes them straight in,
dropping a `to_type()` round trip.

Two consequences, both taken rather than worked around:

* `Layered::layer_types` was `Vec<syn::Type>`, built by mapping `.syntax()` over
  `TypeRef::layer_types()` — the same discard one layer down. It now carries
  readings, which is what let the `unrequire_output` call site pass one.
* `unrequire_input` has no callers once `run.rs` uses `clear_root`, so it is
  deleted rather than kept as dead code behind a symmetry argument. Two lines to
  restore if an input-side caller appears.

Verified: 551 lib tests, `--all --all-features` 14/14 suites, clippy
`--deny warnings` clean, fmt (CLI config), regen-check byte-identical after
`git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48. No
`.syntax()` downgrade remains at any registry door.
milyin added 3 commits August 2, 2026 16:51
…285)

First of #284's three steps, and it is a PREREQUISITE rather than a win on its
own — stated plainly because the numbers say so:

  .syntax() calls   19 -> 24   (UP: selector 15 -> 15, trait_impl 4 -> 9)
  boundary ledger   127 -> 127 (unchanged)

My own plan predicted the ledger would fall here. It does not, and reading the
sites says why: selector's 3 and trait_impl's 11 counted matches are
`is_unsized_spelling`, `decoded_vec_satisfies` and the `Type::Reference`
bridgeability guards — all genuine SPELLING questions, and the documented
exemption. Nothing was owed there.

What it does change is the type of the inner parameter. `input_wrapper_shape` /
`output_wrapper_shape` and their four sub-handlers took `t1: &syn::Type`, and
`selector.rs` produced it by destructuring a reading it already held. Now `t1`
is a `&TypeRef`, so a handler cannot be reached with tokens that have no
reading, and #284's step 2 has something to pass to `input_entry`/`output_entry`
when those take a reading.

`produced` deliberately stays a `&syn::Type`, and the slice case is why: at
`selector.rs`'s `&[T]` arm the adapter COMPOSES `Vec<#elem>`, and #280 sealed
minting to the model — `api::lang` has no `Vec<T>` reading to make. That turns
out to be consistent rather than awkward: `produced` is defined as the tokens
the converter yields, and every question asked of it
(`is_canonical_spelling`, the `Type::Reference` guards) is a spelling question.
So the split is meaningful — `produced` = what is emitted, `t1` = what is
wrapped.

Verified: 551 lib tests, `--all --all-features` 14/14 suites, clippy
`--deny warnings` clean, fmt (CLI config), regen-check byte-identical after
`git clean -fd examples/` + `cargo clean -p` (rebuild confirmed by the
Compiling lines), covertest-kotlin 48/48.
Second of #284's three steps, and the one that pays for the first.

  reading(&TypeKey)                      the ONE keyed door
  conversion/input_entry/output_entry    take &TypeRef
  reading_of(&syn::Type)                 the visible "I only had tokens" step

The guarantee: an entry lookup cannot be called about a type the registry does
not know. #280 sealed minting, so a `TypeRef` can only come from the model or
from `reading`/`reading_of` — and those answer `None` for an unregistered type,
which the caller must now handle. Before, any tokens could be passed and got a
silent `None` back.

The important find was NOT in the trait. `Registry` carried INHERENT
`input_entry`/`output_entry` taking a `&syn::Type` (`scan.rs:579/585`), and an
inherent method wins over a trait method on a concrete receiver — so every
caller holding a `Registry` used the spelling door and the trait's signature
could not close it. Changing the trait alone left 104 sites silently compiling
against the old path; closing the inherent pair is what surfaced them. Same
"second door inside the room" that hid `classify` behind `Registry::reading`
until #267, and it is the reason this PR is larger than the plan predicted.

PR #285's payoff lands here: every `t1_ty` in the wrapper-shape handlers became
`t1`, because the handler already holds the reading.

Honest numbers:

  to_type() (prod)   45 -> 37
  boundary ledger    127 -> 127 (unchanged, and structurally so — it counts syn
                     variant mentions, and a signature change names none)

The 37 that remain are spelling needs — C type names, `quote!` targets,
diagnostics. NONE feeds a lookup, which is this issue's acceptance test:
`grep to_type() | grep -E "input_entry|output_entry|conversion|reading"` is
empty.

`reading_of` is deliberately not a convenience wrapper for the entry lookups. It
returns a reading, so the `None` stays visible at the call site; a
spelling-taking `entry_of` would have restored exactly the door being removed.

Verified: 551 lib tests, `--all --all-features` 14/14 suites, clippy
`--deny warnings` clean, fmt (CLI config), regen-check byte-identical after
`git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48.
First piece of #284 step 3. Small on purpose — see the finding below for why the
rest is not a helper swap.

`struct_plan.rs` already held `reading`, `optional_inner` and `bare_ref` as
`TypeRef`s, then downgraded all three to spellings and looked them back up:

    let effective_ty = reading.syntax().clone();
    ...
    registry.reading_of(&effective_ty).and_then(|tr| registry.output_entry(&tr))?

Three of those round trips are gone; the file now has ZERO `reading_of` calls.

Two of the five sites were not round trips but latent defects of the #270/#272
family — asking a spelling a question the model answers:

  * `pat_match_top(&slot_ty, "Vec")` compares the last path segment, so a
    `Box<Vec<T>>` answered FALSE. Now `slot.sequence_elem().is_some()`.
  * `bare_path_ident(&slot_ty)` takes the spelling apart to get a name, which
    answers about the WRAPPER for `Box<T>`. Now the name comes off
    `TypeKind::Named { id }`.

Neither is reachable from the in-tree examples — goldens are byte-identical — so
these are the same "correct output, no signal" shape as #266/#273 rather than
observed breakage.

FINDING that resizes the rest of step 3: `emit/flat_input.rs` holds 20 of the 34
`option_inner_type` callers and 12 `reading_of` sites, and the reason is not the
helper — it walks `syn::Fields::Named` directly, while `flat::Struct::fields`
already carries a `TypeRef` per field. So that file needs the ELEMENT-WALKING
change (take `&flat::Struct`, walk `struct.fields`), which is the same follow-up
the umbrella records from #283 for `scan_struct`/`scan_enum` — not a peel
substitution. It is its own PR rather than a rushed extension of this one.

Ledger unchanged at 127: `types_util` only falls when its callers stop needing
it, and `option_inner_type` still has 34.

Verified: 551 lib tests, `--all --all-features` 14/14 suites, clippy
`--deny warnings` clean, fmt (CLI config), regen-check byte-identical after
`git clean -fd examples/` + `cargo clean -p`, covertest-kotlin 48/48.
Continues #284 step 3: the remaining `option_inner_type` / `reading_of` sites,
one consumer at a time.

`TypeRef` gains three accessors, each answering a question a spelling was being
asked before: `callback_args()` (the reading counterpart of the
`extract_fn_trait_args` *classifier*), `erased_wrapper()`, and jnigen's
`enum_probe()` + `Declarations::is_kotlin_enum_reading()`.

Converted: `classify_leaf` (now zero `reading_of` calls and no spelling local at
all), `build_flat_input_plan`, `build_option_scalar_input_plan`,
`vec_build_elem`/`vec_build_helpers`/`collect_vec_build_elem_types`,
`sum_ctor_arg`, four round-tripped entry lookups, and `PlanError`, which now
carries `Box<TypeRef>` and names a source position.

Two helpers fell out **provably dead** — `impl_into_target` (the model refuses
`impl Trait` that is not the callback form, so it was already unreachable before
this branch; `cargo check` said so) and `slice_or_vec_elem`. Both are replaced
by a comment recording what stood there. `extract_fn_trait_args` is gone from
jnigen production code entirely.

Fixed along the way: `Box<Priority>` and `Option<Box<Priority>>` now reach their
`enum_class!` declaration instead of missing on a `Box < Priority >` key; and
`build_output`'s two distinct failures no longer share one message that gave
correct advice for one and actively wrong advice for the other.

Ledgers move **down**, which is the direction they exist to reward: boundary
127 → 122, spelling census `vec_build.rs` and `kotlin_emit.rs` to zero.

## Review found a real defect, twice

The rule "model peels are always better" is **not** unconditional, and I applied
it past an exception the codebase already documented (`decoded_vec_satisfies`).
The sharper split:

* **`kind` decides what the destination sees** — surface type *and wire*.
* **`syntax` decides how the value is converted**, and Rust tells apart what the
  model erases.

The specialized input lowerings do not decode their parameter, they **rebuild**
it — so selecting them off `kind` alone made a `Box<Option<T>>` parameter
receive a bare `Option<T>`: `E0308` in the generated crate. Round two found the
same defect one layer out: an erasure sits *outside* the layer it wraps, so
`Box<&Vec<T>>` classifies as `Ref` and a guard that reads `kind` first discards
the wrapper before looking.

Both are fixed by asking the **model** (`erased_wrapper()`, since it is the only
thing holding both halves) before each peel, never by re-adding spelling probes:
net spelling probes added is zero. Refusing is a **gap, not a requirement** —
`Box::new(v)` is what the syntax asks for — so #292 tracks rebuilding instead of
refusing, along with the wire-from-`kind` rule (#230's real diagnosis) and the
stripped-spelling model facts a rebuild needs.

Two regression tests, each on a **control pair** so it cannot pass vacuously,
and the ordering one verified to fail when its guard is disabled. Generated Rust
is never compiled by this suite (#269), so they pin that the emitter is never
*asked* to write the ill-typed code rather than the `E0308` itself; their docs
say so.

Also fixes a `cargo fmt --check` failure that had already turned CI red.
milyin added 2 commits August 2, 2026 20:42
…293)

`TypeKind` erases `Box`/`Cow`, which is right: `Box<Option<T>>` is one optional
to every destination language. But conversion follows the SYNTAX, and the two
facts a rebuild needs were not on the model.

`TypeRef::erased_wrappers()` and `stripped_syntax()`, both derived from the
spelling rather than stored — `lower_type` keeps discarding the wrapper, and
nothing new can disagree with `syntax`. The stripped spelling is defined by its
invariant, not by its loop: it is the spelling whose own lowering yields exactly
this `kind`, so the peel runs to a fixed point. `erased_wrapper()` becomes the
head of the list.

An erasure sits OUTSIDE the layer it wraps, so both answer for one layer's
spelling only; the tests pin that with the pair `Box<&Vec<T>>` / `&Box<Vec<T>>`,
each invisible to the other's vantage point.

The audit found one live miscompilation. Builder delivery binds the returned
value and matches it against `Option`'s patterns, which match ergonomics does not
see through a `Box` — every other peel site classifies, which is the erasure
working. `read_through_erased_wrappers` undoes them at the single point the value
enters the delivery. Its fixture is in perftest-flat, whose binding covertest
compiles: verified by disabling the fix and watching `E0308`, and round-tripped
on the JVM.

`Box`'s read op drops its parens — every consumer splices into a `let`
initializer, where converters happen to `#[allow(unused_parens)]` and wrapper
externs do not.

Refs #292 (item 1), #229 (L4/L5).
#292 item 3, and #289 with it — the two are one change because #289 alone breaks
the build: reading a field's layer off the model is what makes the emitter
rebuild an `Option` for a slot ascribed `Box<Option<_>>`.

`build_through_erased_wrappers` is the input dual of #293's reader, on the same
`WRAPPER_OPS` rows, applied innermost-out. The three specialized input lowerings
descend instead of refusing, collecting each layer's wrappers on the way down —
an erasure sits outside the layer it wraps. A layer's wrappers are applied only
where that layer exists; applying them unconditionally double-wraps when two
layers are the same reading.

`Cow` keeps `build: None` as POLICY, not impossibility: `Cow::Owned(v)` is
well-typed, but always-Owned pays a copy per call and removes the borrow path the
source asked for, observably.

Two findings the fixtures forced out:

* **A wrapper silently cost a parameter its lowering.** The data-class
  declaration was keyed by the wrapped spelling, so `Box<Payload>` found no
  `Payload` declaration and fell to the general converter — no error, no diff.
  Declarations are keyed by `stripped_key()` now; conversions keep `key()`.
* **A wrapper over a terminal had no converter at all.**
  `input_transparent_bridge` delegates to the stripped spelling and re-wraps,
  tried last so no existing route changes.

Refused with stated reasons: `Box<&T>` (a converter yields an owned value),
`&Box<Vec<T>>` (needs a per-call clone), `Vec<Box<T>>` elements (helper-trio
name collision — see the follow-up, this one is soft).

#289: `build_flat_struct_node` takes `flat::Struct` and peels its fields off the
model. Both censuses move DOWN — spelling helpers 18 → 9, ledger 127 → 126 — the
first in the #284 chain to do so, because it retires callers rather than
re-typing signatures.

Review catch, fixed in `eb9df58`: the wrap refactor had hoisted an optional
node's field decodes out of its presence gate, so a null object's inert
placeholders were decoded — a required handle field's pointer `0` reads as a
closed handle and `null` became an error instead of `None`. `Holder` is the
fixture that shows it.

Every wrap verified by disabling it and reading the error naming its shape.
Against the merge base the generated bindings have zero genuinely-removed lines.

Closes #289.
milyin added a commit that referenced this pull request Aug 2, 2026
#292 item 2 stated the invariant as "same `kind` ⇒ same wire". That is false,
and prebindgen violates it on purpose: jnigen crosses `&[Payload]` as a jlong
Vec handle and `Vec<Box<Payload>>` as a `JObject`, both surfacing as
`List<Payload>`. Choosing a wire is the generator's job, and the wrapper absorbs
the difference — a caller cannot tell.

What a caller CAN tell, and what the erasure promises will not happen, is the
destination-language **type** changing because the source spelled a `Box`. So
the rule is: same `kind` ⇒ same destination-language type; the wire is free.

It scopes to CONVERTED positions. A `repr_c_struct` is a layout mirror —
reinterpreted from the source struct's bytes — so its field types are a layout
fact, `Box<T>` really is a different C type from `T`, and the spelling is
load-bearing by construction. That is the one place the usual split inverts, and
it is why #230's headline example (`Payload.label`) is not a defect.

Reusing a mirror's spelling test in a converted position is how the rule breaks.
A tagged-union payload is converted, and took its opaque-pointer arm from the
`Box` in the spelling: `Option<Box<Handle>>` crossed as `handle_t *` while
`Option<Handle>` — the same optional handle to every destination — was REFUSED,
falling through to a converter-agreement check its structural output marker
(`()`) can never pass. An erased wrapper decided expressibility, which is the
same defect shape #292 found on the jnigen side.

The arm asks the declaration now, off the model. All three spellings —
`Option<Box<Handle>>`, `Option<Handle>`, `Handle` — present `*mut handle_t`, and
their converter BODIES differ: the boxed one hands over the box it has, the
others are boxed by the converter. The C type follows `kind`; the conversion
follows the syntax.

Pure addition — previously-refused shapes now resolve, and the regen is
byte-identical against the merge base.

Refs #292 (item 2), #230, #229.
milyin added 14 commits August 2, 2026 22:53
#292 item 2, with the rule restated — as written it was wrong.

"same `kind` ⇒ same wire" is false. The wire is the generator's to choose, and
prebindgen varies it deliberately: jnigen crosses `&[Payload]` as a jlong Vec
handle and `Vec<Box<Payload>>` as a `JObject`, both surfacing as `List<Payload>`.
The wrapper absorbs the difference and a caller cannot tell.

What a caller CAN tell, and what the erasure promises will not happen, is the
destination-language **type** changing because the source spelled a `Box`:

    Same `kind` ⇒ same destination-language type. The wire is free.

It scopes to CONVERTED positions. A `repr_c_struct` is a layout mirror,
reinterpreted from the source struct's bytes, so its field types are a layout
fact — `Box<T>` (a pointer) really is a different C type from `T` (inline) and
the spelling is load-bearing by construction. That is the one place the usual
split inverts, and it is why #230's headline example (`Payload.label`) is not a
defect.

Reusing a mirror's spelling test in a converted position is how the rule breaks.
A tagged-union payload is converted, and took its opaque-pointer arm from the
`Box` in the spelling: `Option<Box<Handle>>` crossed as `handle_t *` while
`Option<Handle>` — the same optional handle to every destination — was REFUSED,
its structural output marker (`()`) unable to pass the converter-agreement check.
An erased wrapper decided expressibility, the same defect shape #292 found on the
jnigen side.

The arm asks the declaration now, off the model. All three spellings —
`Option<Box<Handle>>`, `Option<Handle>`, `Handle` — present `*mut handle_t`, and
their converter bodies differ: the boxed one hands over the box it has, the others
are boxed by the converter. The C type follows `kind`; the conversion follows the
syntax.

Pure addition: `mirror_field_wire` is still consulted first, previously-refused
shapes now resolve, and the regen is byte-identical against the merge base.

Refs #292, #230.
…297)

#294 called it definitive on the grounds that the only alternative was spelling a
Rust wrapper into a JNI symbol. That is a false dichotomy: keying the helper trio
on the CANONICAL element gives one trio per Kotlin class, with the element's
wrapper applied where the Vec is consumed. #296 has the sketch.

The cost of leaving it is not correctness but a silent downgrade — a `Box` the
model erases turns raw scalar leaves into a per-element JObject plus a field read
per field.

Refs #296.
…ge A) (#298)

`immediate_edges` asked for a `&syn::Type` and opened by re-keying it —
twice, once for the structural children and once for the declared fields.
So `resolve.rs`, `order.rs` and `register_type_inner` each spelled a key
into tokens purely so the callee could undo that: a normalize pass and a
token render per call, to arrive back where it started. The most common
use of `TypeKey::to_type()` was undoing itself.

It takes a `&TypeKey` now. A table lookup takes an identity.

The same round trip, one layer out, ran at every `key.to_type()` fed
straight to `reading_of` — which is `reading(&TypeKey::from_type(..))`.
Those become `reading(key)`, the route #284 already moved jnigen's
`convert_crossing` to, and any spelling they still need comes off the
reading rather than off the key.

Two sites keep a spelling and say why: `order.rs`'s `plan_edges` needs
real tokens for `extract_fn_trait_args`, and cbindgen's selector chain
still takes `&syn::Type` — both now read them from the cell the registry
already holds, so nothing is re-derived from a key.

`to_type()` is not removed here and `TypeKey` is unchanged: this proves
the `reading(key)` route before anything depends on it. 44 call sites to
31, all of it deletion.

Verified: 631 lib tests, `cargo test --all --all-features`, clippy on
1.85.0 and stable, and regen-check byte-identical after a forced rebuild
plus covertest-kotlin's 49-section JVM harness.
* A declaration keeps the type it was written with (#291 stage B)

`ptr_class!(Foo)` receives a real `syn::Type`, reduces it to a key, and
throws it away. Everything that later needed those tokens back — to
`intern` the type, to spell `Into<#target>`, to say whether the build
script path-qualified it — asked the KEY to reproduce them. That is
backwards: the declaration is where the type came from.

So declarations carry `Origin<syn::Type>` now, the model's own convention
for a node's tokens, at `SourceLocation::default()` — the sanctioned
placeless location for something a build script authored rather than a
captured file.

`RegistryBuilder::export_type` takes the type instead of the key, like
its sibling `cross` already did, and `Declared::types` /
`Decompositions::replaces` carry the spelling beside the identity. That
is what unblocks the two sites a key genuinely could not serve: the
qualified-declared-types diagnostic needs multi-segment path STRUCTURE,
and the declared-type scan needs real tokens for `intern` — for a type
that is in no table yet, so `reading()` has nothing to answer with. Both
canonicalize explicitly at the point of use, which is what the key was
silently providing; the comments say so.

The same applies to every declare-phase consumer. `build_expansions`,
`build_deconstructors`, `convert_input_body`, `build_sum_decons` and
`validate_split_declarations` run while only a `RegistryBuilder` exists,
where `reading()` would legitimately answer `None` — swapping them to it
would have been a silent semantic change, not a refactor. They read
their own decl.

Two sites go the other way, to `reading()`, because they are past the
declare phase and the sibling arm beside each already did: `SpecKey::
WholeFolder` in `derive_iface_spec` (which is contractually a pure
function of its key, so a side channel was not open to it), and
cbindgen's callback structs, which now read the argument types their
declaration recorded rather than rebuilding them from a `Vec<TypeKey>`.

28 `to_type()` call sites to 9, and every one that remains is a name
lookup or the idempotence test — stages C1 and D.

Verified: 631 lib tests, `cargo test --all --all-features`, clippy on
1.85.0 and stable, fmt, regen-check byte-identical after a forced
rebuild, and covertest-kotlin's 49-section JVM harness.

* Review: exporting a type twice keeps the first spelling

`declared.types` was a `HashSet<TypeKey>`, so a repeated `export_type`
was first-wins on the identity. Turning it into a map made `insert`
overwrite the stored spelling — last-wins, and only for the spelling,
which is the one thing about the pair that is not fixed by construction.
`register_class` already documents keeping the first for a reopened
declarator; `export_type` says and does the same now.

Also from review: the fixture types in `write/tests.rs` were still named
`key_a`/`key_b` after becoming `syn::Type`s, and `declared_origin`'s
intra-doc link pointed at `crate::core::RegistryBuilder`, which is not
re-exported there.

Verified: 631 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
byte-identical after a forced rebuild, covertest-kotlin 49/49.
* A key answers what it is called (#291 stage C1)

Eight sites asked `to_type()` for a whole `syn::Type` and then threw all
of it away but one ident. They were not asking for syntax; they were
asking the key what it is called, and a key can answer that itself.

    TypeKey::ident()      -> Option<syn::Ident>   // bare_path_ident's rule
    TypeKey::short_name() -> Option<String>       // last segment, generics and all

Two, because the incumbent walks genuinely differ: `bare_path_ident`
refuses a type carrying generic arguments, while the Kotlin class-name
derivation reads `Publisher<'static>` as `Publisher` — a declaration
writes the latter and means the class. Keeping one accessor would have
had to pick a winner and silently change one set of call sites.

Both read the canonical string, and that is deliberate rather than a
shortcut: `canon` is a token-stream rendering, so tokens are
space-separated (`Vec < u8 >`, `& Foo`, `a :: Foo`), which puts a path's
head before the first `<` and its last segment after the last `::`, with
`syn::parse_str::<syn::Ident>` as the total validator on the far end.
Reparsing the type instead would make a NAME depend on a
serialize-then-reparse round trip, which is the dependency #95 removed.

`key_name_accessors_match_the_syn_walks` is the warrant: sixteen shapes —
bare and qualified paths, generics, references, slices, arrays, tuples,
unit, raw pointers, trait objects, fn pointers — each asserted equal to
the walk it replaces. It also pins the one documented limit, a
qualified-self path answering `None`; `scan_declared_items` refuses one,
and refusing beats guessing for a shape this cannot read.

The boundary ledger moved 120 -> 117 and `kotlin_emit.rs` left it
entirely: these were real source-syntax classification sites, not just
call-site noise.

Verified: 634 lib tests, `cargo test --all --all-features`, clippy on
1.85.0 and stable, fmt, regen-check byte-identical after a forced
rebuild, and covertest-kotlin's 49-section JVM harness.

* Review: a path segment is not the last thing before a `<`

The string walk split at the FIRST `<` and took the last `::` of what was
left. That gets `Vec<a::B>` right — the `::` belongs to the argument — and
`a::Foo<u8>::Bar` wrong: `short_name` answered `Foo`, and `ident`
answered `None` because the canon contained a `<` at all, where both syn
walks answer `Bar`. Generic arguments on a NON-FINAL segment were the
case the split could not see, and the migrated Kotlin lookups would have
resolved the wrong name or silently skipped the declaration.

The walk is nesting-aware now: `path_segments` tracks angle depth, splits
on `::` only at depth 0, and takes each segment's arguments from that
segment. `ident` then applies `bare_path_ident`'s actual rule — arguments
on the LAST segment only — instead of on the whole string.

Two things fell out of doing it properly:

* A qualified-self path no longer needs its documented exception. syn
  keeps only `Item` of `<T as Tr>::Item` in `path.segments`, and skipping
  the leading group reads it the same way, so the accessors now MATCH the
  walk there rather than declining.
* The `>` of a bare fn's `->` is an arrow, not a bracket. Miscounting it
  makes the `::` inside `Vec<fn() -> a::B>` look top-level, which would
  answer `B` for a type whose name is `Vec`.

Both reviewers also noted `ident` did redundant work — it built a String
via `short_name`, then reparsed it. It shares the one scan now and parses
each segment once.

SHAPES grows from 16 to 24, with the three cases above plus the ones that
pull against them, and three focused tests pin the last-segment rule, the
qualified-self tail, and separators inside arguments.

Verified: 636 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
byte-identical after a forced rebuild, covertest-kotlin 49/49.
* A key is only an identity (#291 stage D, closes #291)

The last three readers, then the channel itself.

`option_depth` peeled `Option<…>` tokens to count layers the model had
already counted: `TypeKind::Optional` is produced for exactly `Option<T>`,
and `optional_inner()` names the layer. It takes the reading now, and its
two callers hand over the readings they were already sitting next to.

`KotlinMeta::value_rust_key` held a `TypeKey` and had exactly ONE reader,
which immediately spent it on `to_type()`. It was never an identity here
— only a detour through one — and both producers have the `syn::Type` in
hand. It is `value_rust_type: Option<syn::Type>` now, carrying the same
canonical form it always yielded.

The idempotence assertion in `typekey_normalizes_equivalent_spellings`
re-keyed `to_type()`, which was really a claim about the parsed form a
key kept beside its string. A key keeps no such thing; the next line's
string round trip is the whole claim and it stays.

Then:

    pub struct TypeKey {
        canon: std::rc::Rc<str>,
    }

`from_type` stops allocating the second `Rc`. `parse` still parses, to
VALIDATE, and discards it. `Eq`/`Hash`/`Ord`/`Debug`/`Display` never read
anything else, so nothing about identity or error text moves.

What this closes is not a miscompilation — none was known. It is that the
type system permitted a category of mistake: spell a type nobody
classified. #280 sealed `TypeRef` so only the model may mint a reading,
and a key that hands out tokens walked straight around that seal. The
route from a key to syntax is `Conversions::reading` + `TypeRef::syntax`,
and now it is the only one.

44 call sites to 0, across four PRs, with the generated output
byte-identical at every step.

Verified: 634 lib tests, `cargo test --all --all-features`, clippy on
1.85.0 and stable, fmt, regen-check byte-identical after a forced
rebuild, and covertest-kotlin's 49-section JVM harness.

* Review: say what a missing reading means instead of asserting it cannot happen

`c_domain_niches` carried a comment claiming every crossing key has a
cell, next to a `filter_map` that would have silently dropped one if it
did not. Review is right that a claim the code does not check is worse
than no claim.

It is not an `expect`, though. A crossing with no reading contributes no
demand, and that is an ANSWER: the niche allocator reserves values no
sibling conversion can produce, and a crossing the registry never entered
has no conversion to produce one. So the arm is an explicit `0` — the
same answer jnigen's twin at `conversion_domain_niches` already gives —
and the reasoning lives in the code rather than in a claim a `filter_map`
was quietly leaning on.

Also from review: the `classify_return` comment still said the peeled
type "comes straight off the stored key". It comes off a stored
`syn::Type` now, and the fallback beside it is not a miss — the field is
`None` exactly for plain values and arity-0 converters, which have no
inner identity to peel to.

Verified: 636 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
byte-identical after a forced rebuild, covertest-kotlin 49/49.
…riptor (#289) (#302)

* jnigen: the jobject decoder reads the element, and finds a wrong descriptor

`struct_input_body` took the `syn::ItemStruct` and walked
`syn::Fields::Named`, while its caller was already holding the
`flat::Struct` that `struct_type()` handed back — and `flat::Field::ty`
is a `TypeRef` the model classified when it parsed the item. So the
whole-object `.jobject_input()` decoder re-derived by token what the
element had already answered, four times per field.

It takes `&flat::Struct` now and asks the optional layer ONCE, the way
`build_flat_struct_node` has since #294. The name comes off
`TypeKind::Named`, "is it a run" off `sequence_elem()`, the enum probe
off `is_kotlin_enum_reading` — each with the precedent #288 set in
`struct_plan.rs`.

**This one is not output-preserving, and that is the finding.**

`WrappedFields { boxed: Box<Option<i64>>, plain: Option<i64> }` is the
fixture #294 added because those two fields MEAN the same thing. Kotlin
declares both `Long?`. The old emitter asked JNI for:

    "boxed" -> Ljava/lang/Object;
    "plain" -> Ljava/lang/Long;

`option_inner_type` reads the last path segment, so `Box<Option<i64>>`
answered "not optional", the descriptor chain fell through to its
`Object` fallback, and the twins diverged — the #273 signature exactly.
`GetFieldID` requires the field's EXACT declared descriptor, so that
lookup throws `NoSuchFieldError`. The golden now says `Ljava/lang/Long;`
for both.

It was never observed because `JObject_to_WrappedFields_*` is emitted and
never called — this fixture crosses via the flatten path, and its
siblings appear seven times each. Any `.jobject_input()` data class with
a wrapped optional field would have hit it.

Two residuals #294 left inside the converted fn go too: an
`is_kotlin_enum` on a spelling three lines from the reading, and a
`reading_of` re-looking-up a `&TypeRef` already in hand.

`FlatFieldNode::Value::direct_handle` becomes `Option<Box<syn::Type>>`
carrying the handle target the plan peeled, instead of a `bool` beside a
spelling the renderer re-peeled with the same last-segment test — a
`Box<Option<T>>` handle field would have been handed the wrong
`Box::from_raw` target. The node stays token-carrying; it is an emission
IR, and the answer travels from where the reading was.

Ledger 117 -> 116, jnigen census 9 -> 4. `types_util` does NOT move and
was never going to: `option_inner_type` keeps callers in struct_out,
trait_impl, fold, fn_plan and wrapper. The remaining four are the sum
side, which needs `Type::Variant` rather than the `syn::ItemEnum`
`enum_item` hands back — the rest of #289.

Verified: 636 lib tests, clippy on 1.85.0 and stable, fmt,
covertest-kotlin 49/49, and regen-check clean apart from the one
diagnosed descriptor.

* Review: a struct keeps its own constructor delimiters

The `syn::Fields::Named` guard this walk replaced refused a unit struct by
returning `None`. The per-field name check that replaced it cannot: an
empty struct has no field to refuse, so the loop fell straight through to
a hard-coded braced initializer and emitted `myflat::Unit {}` for
`pub struct Unit;`. That is not Rust.

`flat::Struct` does not record whether its fields were named — that is
spelling — and `Struct::spell` is the one place those delimiters are
chosen. It is the exact dual of the `Alternative::spell` the sum decoder
uses so `enum E { B() }` is written `E::B()`; I used the model's helper
there and hand-rolled the braces here, which is the whole defect.

`empty_structs_keep_their_own_constructor_delimiters` covers it, and
fails without the fix. A tuple struct is deliberately absent from it: the
model reads one as an `Extern`, so `Flat::struct_type` answers `None` and
it never reaches this decoder — declaring one as a `jobject_input` data
class fails to resolve instead.

Goldens byte-identical: no in-tree example declares a payload-less
jobject_input data class, which is why the shape had no signal.

Verified: 637 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
after a forced rebuild, covertest-kotlin 49/49.
`sum_input_body` took the `syn::ItemEnum` and ran two zips to get back to
per-field types: a `SumSpec` derived from the item, paired against the
item it was derived from. `Alternative` already IS that pairing — name,
index, and a `Vec<Field>` whose `ty` is a `TypeRef` — so both zips and
the `SumSpec` go, and the payload reads ask the model.

It needs a different accessor to get there. `Flat::enum_item` hands back
only the `syn::ItemEnum`, deliberately: its own doc says a consumer that
acts on the Variant/Enum distinction should ask `declared_type`. Both
`sum_input_body` and `build_flat_sum_field` do that now and match
`Type::Variant`.

The constructor's delimiters come from `Alternative::spell`, which is the
one place they are chosen. That is not a tidy-up: `Alternative::is_empty`
is the GROUP question — `B`, `B()` and `B {}` are all empty by it — and
Rust demands the delimiters wherever the last two are named, so a
three-arm `syn::Fields` match was the only thing standing in for a helper
the model already owns. `Field::bind` shapes each init the same way.

`sum_field_prop_name` takes a `&syn::Member` instead of a `&SumField`.
The member is the whole of what the name depends on, so a caller holding
a `flat::Field` asks `Field::member()` and a caller holding a `SumField`
reads its own — one derivation for both, rather than a second convention
that could drift from the sealed-interface emitter's.

**flat_input.rs is now at zero on every count.** No `option_inner_type`,
no `reading_of`, no `bare_path_ident`, no `pat_match_top`, no `SumSpec`,
no `syn::Fields` — the file comes off the spelling census (4 -> 0, and
18 -> 0 across #294 + #302 + this).

The boundary ledger does NOT move, and should not: its four remaining
entries in this file classify `entry.destination`, a wire type the
adapter itself produced, which is legitimately its business rather than a
reading it should have asked for.

`SumSpec` stays for now — it still has callers in `struct_plan`,
`kotlin_emit` and `sum_out`, and retiring it crate-wide would put the
Kotlin emitters and the sum OUTPUT path in a PR about input decoding. It
owns one thing the model does not, the leaf-naming convention, so that
has to be rehomed rather than deleted.

Verified: 636 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
BYTE-IDENTICAL after a forced rebuild, covertest-kotlin 49/49.
`encode_sum_group` built each arm's pattern by branching on
`variant.fields.first()`, so an alternative with no fields took the
`None` arm and was spelled bare. For `enum E { B() }` and `enum E { B {} }`
that emits `myflat::E::B`, which is E0533 in pattern position: a
zero-field tuple or struct variant still needs its delimiters.

Branching on the first field cannot answer this, because an EMPTY
alternative has no first field to branch on — the same shape as the empty
struct that had no field to refuse, which is how #302 came to emit
`myflat::Unit {}`. That is now three instances of one defect class in
this area: a constructor caught in review (#302), a constructor avoided
by using the model's helper (#303), and this pattern, which nothing had
found.

`Alternative::spell` is the fix, and its doc names the case: "the one
place those delimiters are chosen — for match patterns and constructors
alike, in either direction". This is the pattern half of that sentence;
`Field::bind` shapes each binding the same way.

Getting there needs the element rather than the item, so the arm list
comes off `Flat::declared_type` -> `Type::Variant` and walks
`alternatives`. `enum_item` hands back only the `syn::ItemEnum`,
deliberately — its own doc says a consumer acting on the Variant/Enum
distinction should ask `declared_type`. The tag is `alt.index`, which is
what `SumVariant::tag` was a copy of.

`empty_sum_alternatives_keep_their_own_pattern_delimiters` covers all
three shapes and fails against the old branch.

Goldens byte-identical: no in-tree fixture declares an empty non-unit
alternative, which is exactly why this had no signal.

Verified: 638 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
after a forced rebuild, covertest-kotlin 49/49.
* core: delete `SumSpec` — the model already describes a sum

`SumSpec`/`SumVariant`/`SumField` described a data-carrying enum as a tag
plus one leaf group per variant. `flat::Variant` describes the same thing,
and better: `Alternative` carries the name, the declaration-order index
and a `Vec<Field>` whose `ty` is a classified `TypeRef`, where `SumField`
kept a bare `syn::Type`. It was #211's own thesis sitting in `api/core`.

Every live field had an exact model equivalent:

    v.ident -> alt.name          v.tag       -> alt.index
    v.is_unit() -> alt.is_empty()   f.member -> field.member()

and four fields had no reader at all — `SumSpec::{key, source}`,
`SumField::{name, ty}`.

**`SumField::name` is the one that matters, because two comments and I
said it was the blocker.** `emit/sum_out.rs` and `kotlin_emit.rs` both
claimed "`SumSpec` owns the leaf-NAMING convention, which is jnigen's
own", so retiring it would need that rehomed first. Nothing read it.
jnigen names its slots with `sum_field_prop_name` + `sum_slot_fragment`,
a different convention living in `struct_plan.rs`. `SumField::ty` was
dead for the same reason one layer down: every site already took
`alt_field.ty` off the element sitting beside it.

The doc's other premise was also unmet — "both adapters read one
definition instead of growing a private one each" — cbindgen never used
it. The `#[allow(dead_code)]` on all three structs was the tell.

Three of the four remaining sites already held the `&flat::Variant` and
zipped `SumSpec` back against `sum.alternatives`: derived from the item,
then re-paired with the element it was derived from. That is the shape
#303 removed from `flat_input.rs`. The fourth reached `Flat::enum_item`
and moves to `declared_type` -> `Type::Variant`, as #304 did.

Also gone: `kotlin_emit`'s `sum_field_property_name`, a second copy of
`sum_field_prop_name` keyed on the deleted type, and a redundant
`enum_item` lookup in `struct_plan` that sat beside the `declared_type`
doing the real work.

Ledger and census do not move, and were not going to: `SumSpec` names no
`syn::Type` variant, so the ledger never counted it. The five deleted
`types_util` fixtures tested the declaration-order tag, which
`flat/tests/acceptance.rs` already asserts on `Alternative::index`.

Verified: 633 lib tests, clippy on 1.85.0 and stable, fmt, `cargo doc`
clean of the two intra-doc links this orphaned, regen-check
byte-identical after a forced rebuild, covertest-kotlin 49/49.

* Review: one place turns an alternative's index into its wire tag

Three sites did `alt.index as i32` — the leaf's `group`, the Kotlin `when`
arm, and the Rust `match` arm — and a fourth formatted `alt.index`
straight into a `when` arm. All four have to agree, and they agreed by
coincidence rather than by construction. `sum_tag` is the one place.

Review asked for a checked conversion. It is deliberately not one: the
index counts alternatives of a single enum, and an enum with `i32::MAX`
variants is not something rustc can be handed, so `try_from(..).expect(..)`
would put a panic in the working path for a state the compiler cannot
produce. The bound and that reasoning are on the function.

The model keeps `usize`, which is the reason the conversion exists at all
and belongs here. `i32` is `jint` / Kotlin `Int` — a destination-language
width, and `core::flat` states language-neutral facts. cbindgen reads no
model `.index`; the tag width is one adapter's concern, so it lives in
that adapter, beside the `UnfoldLeaf::group: Option<i32>` it feeds. Cheap
to have done otherwise — every other read of a model index is a doc
invariant, an assertion or a `format!` — but it would have put a wire type
in the model to save one cast.

Also from review: the `enum_discriminant_values` rustdoc link said
`Alternative::index` and pointed at the struct.

Verified: 633 lib tests, clippy on 1.85.0 and stable, fmt, `cargo doc`,
regen-check byte-identical after a forced rebuild, covertest-kotlin 49/49.
* core: a `SumTag` selector registers the sum it names (#282)

#282 asked one thing: is the `SumTag` leaf boundary-crossing data, so its
`out_ty` should be registered, or adapter-only metadata that stays
deliberately unregistered? Decision: it gets a cell.

Most of the issue had already landed and its text is stale — `sum_out.rs`
stopped composing `TypeRef::named(enum_ident)` when #280 sealed the
composers, and the leaf now carries `Variant::type_ref()`, the reading the
DECLARATION stored. What was open is registration.

`require_output` could not serve it. That is
`register_type_recursive(.., root = true)`, and a root DEMANDS a converter
— which a sum has no whole-value output form of, so requiring one fails
resolution. Pulling the tag's `i32` in that way is the original reason
`has_converter()` exists. So registration and demand needed separating:
`Registry::reference_output` registers without demanding, and both leaf
loops now split on `has_converter()` instead of filtering by it.

**The invariant, now stated where it is checkable.** Every leaf's `out_ty`
has a table cell; only a converter-bearing leaf is a root. A cell says the
type entered the pipeline, a root says the binding asked for it directly,
an entry says one resolved — three claims, and a `SumTag` leaf makes only
the first. Written on `has_converter`, on `LeafSource::SumTag`, and on
`TypeRef`, whose doc already said a reading claims no converter and now
says it claims no cell either.

**What this buys, which the old test proves.** The invariant held before
only because jnigen happens to declare the sum through `export_type`.
`unfold/tests.rs`'s assertion read `!...is_some_and(|c| c.root)`, which is
also true when the cell is ABSENT — and absent is what it was, since that
fixture's registry declares nothing. It passed for the wrong reason and
could state neither half. It now asserts the invariant over every leaf of
the plan, and fails against the old filter.

The end-to-end claim the acceptance asks for is a new test against a real
`Registry`: for a declared sum, a cell both ways, root cleared both ways,
an input entry (the whole-`JObject` decoder) and no output entry. That
asymmetry is the design — Rust → Kotlin is flattened, always.

Goldens byte-identical, as predicted: `crossings()` seeds from every table
key, so a NEW cell would add a crossing — but jnigen's sum already had one
from its declaration, so nothing entered the order. Ledger and census do
not move; this adds no `syn::Type` match and no spelling-helper call.

Verified: 634 lib tests, clippy on 1.85.0 and stable, fmt, `cargo doc`,
regen-check after a forced rebuild, covertest-kotlin 49/49.

* Review: the fixture's selector carries the sum, so the test pins #282

`reading_sum_decon` said it mirrors the JNI synthesis and gave the tag
leaf `out_ty: i32` — the tag's WIRE type, where `synth_sum_leaves` stores
the sum itself. So the registration assertion proved only that *some*
converter-free leaf gets a cell, not that the selector registers the sum
it names, which is the whole of #282.

I noticed that divergence and wrote around it instead of fixing it, and
the review is right that the acceptance test cannot cover for it:
`sealed_class!(Reading)` creates the `Reading` output cell through
`export_type` whether or not the leaf registers anything. Measured — it
passes against the old filtered loop.

With the fixture carrying `tref(Reading)`, `sum_return_is_a_fixed_builder_plan`
fails against that loop with `leaf `tag` registers its out_ty`. That is
the behaviour #282 decided, pinned.

The acceptance test keeps its own claim — a declared sum's three-part
registry state, including the input/output entry asymmetry — and its doc
now says what it cannot claim, so it is not mistaken for the guard later.

Verified: 634 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
byte-identical after a forced rebuild, covertest-kotlin 49/49.
* jnigen: one reading of the movability rule (#223 item 1)

`plan.rs` says of `steps_are_movable`:

> This is the one place the rule is written… Two readings of it would
> drift, and the disagreement would be a borrow handed to an owning
> converter.

`reach_leaf_flat` was the second reading, spelled
`path.iter().all(PathStep::is_plain_field)`, against `encode_plan_leaves`'
`steps_are_movable(&path)`. They already disagreed: the rule permits a
TRAILING optional field — a `None` arm still hands the whole `Option` over
by value — and the restatement did not. So where `place_is_owned` granted
an owned `out_ty` on the strength of the rule, the emitter projected
`(&(&__src.a).b).clone()` and handed a borrow to the owning converter that
`out_ty` had selected. PR#221's P1, exactly, one path shape away.

Its comment defended the restatement: a trailing optional cannot reach
return delivery, because a nullable leaf is routed to callback delivery in
`single_return`. True — and that is the shape of the hazard, not a defence
against it. A local restatement can disagree with the rule for as long as
an invariant somewhere else keeps the disagreement unreachable.

The optional-step guard beside it had the same shape of hole. It asked its
`path` PARAMETER, and `wrapper.rs` rebases onto a hoisted local and hands
over the remaining suffix — `Hoisted::innermost` having stripped the
prefix that bound it, optional step and all. So the guard passed exactly
when the hoist was the conditional one, which is the case that cannot
compose: an `Option<T>` local with a field read hung off it. It asks
`leaf.path` now.

Both were unreachable, and neither was unreachable for a reason the
emitter states.

**No test called a reach function directly** — every pin was an
end-to-end string match on generated Rust, which is why a latent
divergence had no failing test. Three now do, and each fails against the
code it replaced; the movability one reports the defect verbatim:
`got '(& (& __src . a) . b) . clone ()'`.

They ask `test_util::reading` rather than `Flat::classify`, which #280
sealed to `api::core` — a test under `api::lang` meeting that boundary is
the boundary working.

Goldens byte-identical: neither divergence is reachable today.

Verified: 637 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
after a forced rebuild, covertest-kotlin 49/49.

* Review: the fixtures build leaves the resolver can produce

`leaf()` set `LeafSource::Field` for every fixture, including the two
identity leaves and the one with a `Call` step. Production pairs the
source with the path shape: an identity leaf is `Accessor`
(`unfold.rs`'s `DeconRecord::Identity` arm), and `Field` belongs only to
the synthesized by-value `data_class` decomposition, whose paths are
field idents and never calls. So the fixtures exercised a leaf the
resolver cannot build.

Not cosmetic, because `source` decides the terminal treatment: a `Field`
leaf is CLONED out of the place it reached. That clone was landing in the
movability test's failure output, which reported

    got `(& (& __src . a) . b) . clone ()`

for a defect that, on the accessor leaf this actually models, produces

    got `& (& __src . a) . b`

Same assertion, same discrimination — both tests still fail against both
old implementations — but the evidence now shows what the divergence
really emits rather than a clone the shape would not have.

`source` also stops being a value every fixture happens to share:
`a_field_leaf_is_cloned_out_of_its_place` covers what `Field` means, so
the parameter is load-bearing in the tests as well as in the emitter.

Verified: 638 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
byte-identical after a forced rebuild, covertest-kotlin 49/49.

* jnigen: one name, one enum question (#223 cheap wins)

Three places where one question had two implementations — #223's thesis,
in the naming and classification layer it does not list.

**`sum_slot_name` was a byte-for-byte copy** of `sum_slot_fragment`, same
lower-first-char plus `_` join. `kotlin_emit` called the copy at one site
and the shared `sum_field_prop_name` at two others, so a slot name and the
property inside it came from different files. Deleted.

**`classify_field` asked the spelling where the model has the answer.** It
called `is_kotlin_enum` twice, on two spellings, where `flat_input` asks
`is_kotlin_enum_reading`. `builder.rs` documents the difference: a
`Box<Priority>` field is `false` for the first and `true` for the second,
so a wrapped enum field would classify as a plain leaf and render as its
wire rather than the Kotlin enum class — the #273 family, output-side.

It asks once now, of `bare_ref`, the already-peeled reading sitting beside
it. Optionality stays the caller's fact: `enum_probe` peels `Option` as
well as borrows, so probing the unpeeled reading would make `Priority` and
`Option<Priority>` indistinguishable and collapse two arms into one.

I tried to prove this with a `Box<Priority>` field on `WrappedFields`, the
fixture #294 added for exactly this pairing. **It does not resolve** —
`TypeKey("Box < Priority >")` is unresolved output-side — and reverting
the classification change leaves it failing identically, so that is a
PRE-EXISTING capability gap and not this change's to fix. Reported
separately; the fixture is not in this commit. The change stands on the
question being the right one to ask, not on a demonstration it cannot yet
have.

**Two camel-casers named one Kotlin property.** `render_data_class_source`
DECLARED it with `kt_snake_to_camel`; `flat_input`'s access expression and
its `GetFieldID` slot name used `util::snake_to_camel`, which additionally
lower-cases the first character. They agree for a conventional lower-snake
field and only for that — a field spelled `Xyz` is declared `Xyz` and read
as `xyz`, and `GetFieldID` for a name that is not the declared one fails at
runtime. One `kotlin_property_name` now serves the declaration and both
readers. `snake_to_camel` stays where it names PARAMETERS, a namespace
with no declaration to match; `symbols.rs`'s mangling warning already used
the kept caser and passes pre- and post-mangle names deliberately, so it
needed no change.

Goldens byte-identical for all three, as expected: the two functions were
textually identical, no in-tree field name is unconventional, and the
enum divergence needs a shape that does not currently resolve.

Verified: 637 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
after a forced rebuild, covertest-kotlin 49/49.
…310)

* jnigen: the transparent bridge gets its outbound half (#309)

The model erases `Box` and `Cow` deliberately — `Box<Priority>` IS
`Priority` to every destination language. #294 gave the input selector a
last resort for the case no layer arm claims, a wrapper over a TERMINAL.
`select_output_type` never got the twin: arms 1-3 run and it returns
`None`.

So an erased wrapper resolved inbound and not outbound. `Box<Priority>`
was a parameter this binding could take and a return it could not give,
for a wrapper the model exists to make invisible.

The gap was invisible because a wrapper over something WITH a layer arm
resolves both ways through that arm — `Box<Option<i64>>` works, which
looks like proof `Box` is handled — and `Box<String>` works too, because
the `Str` arm dispatches on `kind()`. Only a wrapper over a plain
`TypeKind::Named` needs the bridge.

One arm covers `Box<Handle>`, `Box<enum>` and `Box<DataClass>` alike:
`output_terminal` misses all three the same way, by keying on the
SPELLING, so no config sits under `Box < Priority >`.

The dual inverts two lines, because the wrappers come off rather than go
on — `read_through_erased_wrappers` was already the operation, and is
already used for this job in `emit/wrapper.rs`. Everything else is
direction-independent: `subs`, `destination`, `niches` and `metadata`
mean the same thing either way, and inheriting the inner's metadata is
what keeps `Box<Priority>` presenting as the Kotlin enum class rather
than losing it behind the wrapper.

Both guards carry over, the second with its own outbound reason: a
borrow's output route is the clone-into-a-fresh-handle arm, which builds
its wire from a reference and hands back no owned value to read the
wrapper off. Inbound the same guard is about `E0106` — the shapes
coincide, the reasons do not.

Placement is symmetric with the input side and deliberate: step 4 sits
after every layer arm, so nothing that resolves today changes route. It
is not reached for `Optional`/`Sequence` failures, which return early
exactly as they do inbound, because a wrapper over those is already
bridged inside the arm.

Measured against the shape that prompted #309: it now emits

    let __inner = *v;
    Priority_to_jint_447102d2(env, __inner)?

Goldens byte-identical — this adds routes for shapes that previously
reached `None`, and no in-tree example has one yet. The fixtures come in
their own commit.

Verified: 638 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
after a forced rebuild.

* jnigen: the transparent bridge runs the inner's stages (#309)

`input_transparent_bridge` called the inner converter's function directly
and left `pre_stages` empty. Every other composing arm goes through
`composed_inner_input` / `composed_inner_output` for one reason: a
`convert!`-declared type reaches its Rust value through those stages.

So a `Box` over one skipped them. Not a subtly wrong value — **the
generated crate does not compile**:

    let __inner = jlong_to_u64_4384a5d6(env, v)?;
    ::std::boxed::Box::new(__inner)
    //                     ^^^^^^^ expected `Duration`, found `u64`  [E0308]

`boxed_duration_echo` is the fixture, and it is load-bearing rather than
illustrative: reverting this commit with it in place fails the build with
that error. `Duration` is `convert!`-declared with `jlong -> u64 ->
Duration`, so the wrapper sits over a chain rather than a single call.
Both directions now emit the full chain, and the Kotlin exercise runs it
at JVM runtime — a `Box<Duration>` crosses exactly as a bare `Duration`
does, which is what the model erasing the wrapper is supposed to mean.

The outbound half added in the previous commit was written this way from
the start; this is its inbound peer, in its own commit because it is a
bug fix rather than the new capability.

Goldens move by the fixture alone (+117 lines, all additions).

Verified: 638 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
after a forced rebuild, covertest-kotlin 49/49 including the new
exercise.

* core: a wrapped spelling is ORDERED after the spelling it delegates to (#309)

Whoever converts `Box<T>` does it by delegating to `T`'s converter and
putting the wrapper back. That is a real dependency, and the `kind` walk
cannot see it: `Box<T>` classifies as whatever `T` is, so the two share a
classification and differ only in spelling.

`subs` said "this is required". Nothing said "this comes first" — and
`convert_with` is a SINGLE pass in dependency order, so a delegating
converter needs its inner already built. `immediate_edges` now yields the
stripped spelling as an edge when the reading has erased wrappers.

**The inbound bridge has been resolving by alphabetical luck since #294.**
Roots are visited in key order, so `Box<Payload>` resolved because some
other root's fields happened to pull `Payload` in earlier. Measured on a
fixture where that luck runs out: renaming `Priority` to `APriority`
makes `Box<APriority>` resolve and leaves `Box<ZSample>` unresolved,
purely because "ZSample" sorts after "Box < ZSample >". A capability that
depends on the alphabet is not one.

`an_erased_wrapper_over_a_terminal_crosses_both_ways` is the acceptance
test for #309 as a whole, and needs both this and the outbound arm: with
either reverted it fails, naming the unresolved wrapped spellings. It
asserts all three terminal kinds together — enum, handle, data class —
because they miss the terminal lookup the same way, which is the claim
that one arm covers them all. It also asserts the wrapped and bare enum
fields present as the SAME Kotlin type, which is what erasing the wrapper
is supposed to mean.

Goldens byte-identical: every in-tree example already resolved, so this
changes no output. It replaces luck with an edge.

Verified: 639 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
after a forced rebuild.

* jnigen: the fixture that could not be built (#309)

`WrappedFields` gained `Box<Priority>` beside `Priority`, the pairing it
already carries for `Box<Option<i64>>` / `Option<i64>`. That first pair
rides the `Optional` layer arm and always worked; the second classifies
as `Named`, no arm claims it, and outbound there was no route at all —
which is what #309 is.

This is the fixture whose failure to build FOUND the gap, while adding a
demonstration for #308's `classify_field` fix. It now builds, and it
proves both:

    public data class WrappedFields(
        val id: Long, val boxed: Long?, val plain: Long?,
        val boxedEnum: Priority, val plainEnum: Priority,
    )

`boxedEnum` presenting as `Priority` rather than as its `Int` wire is
#308's change; that it presents at all is #309's. #308 landed correct and
undemonstrable because no `Box<enum>` field could be built to show it —
this is its first end-to-end evidence.

The Kotlin exercise weighs the two enum fields against each other, so the
claim under test is that a wrapped and a bare spelling of one type behave
alike, not merely that the wrapped one compiles.

Goldens move deliberately: +131 lines, the new capability.

Verified: 639 lib tests, clippy on 1.85.0 and stable, fmt, regen-check
after a forced rebuild, covertest-kotlin 49/49 including the new checks.

* Review: a doc comment goes back to the function it describes

`/// **Input** wrapper shape …` had been stranded since #294 inserted the
transparent bridge between it and `input_wrapper_shape`, which has had no
doc of its own ever since — while `output_wrapper_shape`'s doc calls
itself "the dual of `input_wrapper_shape`", pointing at the undocumented
one.

Adding the OUTBOUND bridge moved that fragment onto an output converter,
where a header reading "**Input** wrapper shape" is not merely stale but
a contradiction. Review caught it there; the fix is to give it back to
its owner rather than to relabel it.

Also from review: the acceptance test claimed "each field type is wrapped
and unwrapped in one struct", and only the enum is. The handle and the
data class are wrapped only, and deliberately — what they show is that
ONE arm serves every terminal kind, where a bare twin of each would test
the terminal lookup instead of the bridge. The comment says that now.

Verified: 639 lib tests, clippy on 1.85.0 and stable, fmt, `cargo doc`,
regen-check byte-identical after a forced rebuild.
* core: a type is its syntax — `TypeKind` stops classifying

`TypeKind` was a **destination-neutral classification**: one variant per
concept a target language would act on, several Rust spellings folding
into each. `String` and `str` were one `Str`; `Vec<T>` and `[T]` one
`Sequence`; `Box<T>` and `Cow<'_, T>` disappeared into what they wrapped.

It leaked, and not at the edges:

* `&T` earned a layer of its own while `Box<T>` was declared transparent
  — two wrappers, opposite treatments, on no principle either adapter
  shared;
* `Cbindgen` picked its C type off the Rust spelling regardless, so the
  neutrality the kind claimed was not what any adapter used;
* every fold had to be **undone** somewhere. `erased_wrappers()` and
  `stripped_syntax()` exist because lowering dropped something a consumer
  needed back.

So `TypeKind` is now the subset of `syn::Type` the flat API accepts, and
nothing else. One variant per accepted form:

    Scalar Str String Unit
    Optional Vec Slice Fallible
    Boxed Cow Uninit
    Array Ref { lifetime, mutable } Named { id, args } Callback

`RefMode` is gone: `&mut MaybeUninit<T>` is `Ref { mutable: true }` over
`Uninit`, the two forms the source wrote. A lifetime and a generic
argument are kept, because they are what the source wrote.

## The folds did not disappear — they moved to where they are decided

Each is one shared reading, taken on purpose at a call site rather than
baked into every classification:

    TypeRef::unwrapped()           Box/Cow peeled       <- the erasure in lower_path
    TypeRef::sequence_elem()       Vec<T>, [T], either  <- TypeKind::Sequence
                                   behind a wrapper
    TypeRef::borrow_target()       past an out-param    <- RefMode::Out's absorption
                                   slot
    TypeRef::is_exclusive_borrow() &mut T, not          <- RefMode::Exclusive
                                   &mut MaybeUninit<T>

Old `x.kind()` is exactly new `x.unwrapped().kind()`, which is what made
the ~30 consumer sites in `registry/scan`, `unfold`, `cbindgen` and
`jnigen` a mechanical rewrite rather than a re-reading of each one.

## What it buys

> **The syntax is recoverable from the kind.**

`TypeKind::to_syn()`, checked against `TypeRef::origin.syntax` over the
acceptance corpus by `syntax_is_recoverable_from_kind` — 27 spellings,
token-exact, including `[u8; TAG_LEN]`, `&'a T`, `Cow<'_, [u8]>` and
`Sample<'a, u8, Vec<u8>>`. Two exemptions, each named in a test of its
own: a callback's bound *order*, and a `Group`/`Paren` the lowering sees
through.

The slice still rides along and generated Rust still spells it — it is
exact and free. It is no longer **load-bearing**, and that is the whole
difference: a fact missing from `kind` used to be invisible, because the
syntax was there to cover for it.

## Also

One new refusal falls out: mid-path generic arguments (`a::B<T>::C`) are
`UnsupportedForm`, since `Named` holds the last segment's arguments and a
spelling this model cannot give back must not be accepted. No flat API
writes that shape.

`peel_transparent` stays — the syntax-side peer of `unwrapped`, for an
adapter comparing a spelling it composed against one it has a converter
for. It lives in `flat` so taking a `syn::Type` apart stays inside the
model.

**Did not move**: every generated artifact byte-identical
(`examples/regen-check.sh`), boundary ledger unchanged, 641 lib tests and
22 doctests green, clippy + fmt clean on 1.85.0 and stable.

* core: a `Cow` is a lifetime and a type, in that order

Review (#312): the `Cow` arm checked the number of **type** arguments and
then plucked the first lifetime out of the list, so three spellings that
are not `Cow`s were accepted and reconstructed as `Cow<'a, u8>`:

    Cow<u8>          no lifetime — not Rust at all
    Cow<u8, 'a>      the arguments, in the wrong order
    Cow<'a, 'b, u8>  two lifetimes, where `Cow` takes one

Each has exactly one type argument, so `arity(1)` passed on all three.
That made "every accepted form spells back what was written" false
outside the corpus — and easy to miss, because `GenericArg` had faithfully
retained the whole list right up until the builtin fold dropped it.

`Cow` is the one builtin with a lifetime in its own signature, so it is
the one whose **whole argument list** has to be validated: it is now
matched as exactly `[Lifetime, Type]`, refused as
`WrongGenericArguments { expected: "Cow<'a, T>" }` otherwise. Which lets
`TypeKind::Cow::lifetime` be a `syn::Lifetime` rather than an `Option`,
and `to_syn` emit the one accepted shape rather than choose between two.

Recoverability is an acceptance rule here, not only a property: a
spelling the model cannot give back is refused where it is read. The
other instance is a generic argument on any but the last path segment.

Also from review: `immediate_edges`' `Ref` arm took its child through
`borrow_target().into_iter().collect()`, which would silently truncate
the graph walk if the accessor and the kind ever disagreed. It is an
`expect` now — the invariant fails loudly or not at all.

Tests: the four refused shapes and the two accepted ones, the latter
asserting the round-trip they exist to protect.
@milyin
milyin marked this pull request as ready for review August 4, 2026 09:06
@milyin
milyin merged commit a548631 into main Aug 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant